feat(search): add task workspace search palette (#2006)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-testid="command-root" data-should-filter={String(shouldFilter)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
CommandDialog: ({ children, open }: { children: ReactNode; open: boolean }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
CommandEmpty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
CommandGroup: ({
|
||||
children,
|
||||
heading,
|
||||
"data-testid": testId,
|
||||
"data-repository": repository,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
heading?: ReactNode;
|
||||
"data-testid"?: string;
|
||||
"data-repository"?: string;
|
||||
}) => (
|
||||
<section data-testid={testId} data-repository={repository}>
|
||||
{heading && <h2>{heading}</h2>}
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
CommandInput: ({
|
||||
placeholder,
|
||||
value,
|
||||
onKeyDown,
|
||||
}: {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}) => (
|
||||
<div data-slot="command-input-wrapper">
|
||||
<input
|
||||
role="combobox"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onKeyDown={onKeyDown}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
CommandItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
CommandList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
CommandShortcut: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/kbd", () => ({
|
||||
Kbd: ({ children }: { children: ReactNode }) => <kbd>{children}</kbd>,
|
||||
KbdGroup: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/badge", () => ({
|
||||
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
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) => (
|
||||
<button data-testid="mock-content-search" onClick={() => onSelect(results[0])}>
|
||||
Content results
|
||||
</button>
|
||||
));
|
||||
|
||||
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> = {}): 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(<CommandPanelView {...props} />);
|
||||
|
||||
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(<CommandPanelView {...props} />);
|
||||
|
||||
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(<CommandPanelView {...viewProps()} />);
|
||||
|
||||
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(<CommandPanelView {...viewProps()} />);
|
||||
|
||||
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(<CommandPanelView {...viewProps()} />);
|
||||
|
||||
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(<CommandPanelView {...props} />);
|
||||
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(
|
||||
<CommandPanelView
|
||||
{...viewProps({
|
||||
mode: "commands",
|
||||
workspaceSearchAvailable: false,
|
||||
onScopeChange,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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(<CommandPanelView {...viewProps({ mode: "commands" })} />);
|
||||
|
||||
expect(screen.getByTestId("command-root").getAttribute("data-should-filter")).toBe("false");
|
||||
});
|
||||
|
||||
it("filters command results before rendering them", () => {
|
||||
render(
|
||||
<CommandPanelView
|
||||
{...viewProps({
|
||||
mode: "commands",
|
||||
search: "needle",
|
||||
commands: [
|
||||
{ id: "matching-command", label: "Needle command", group: "Actions" },
|
||||
{ id: "unrelated-command", label: "Unrelated action", group: "Actions" },
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<CommandPanelView
|
||||
{...viewProps({
|
||||
mode: "search-content",
|
||||
workspaceSearchAvailable: false,
|
||||
onScopeChange,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onScopeChange).toHaveBeenCalledWith("commands");
|
||||
});
|
||||
|
||||
it("groups file matches by repository", () => {
|
||||
render(
|
||||
<CommandPanelView
|
||||
{...viewProps({
|
||||
mode: "search-files",
|
||||
search: "shared",
|
||||
fileResults: [
|
||||
{
|
||||
repository_name: "backend",
|
||||
path: "backend/src/shared-search.go",
|
||||
},
|
||||
{
|
||||
repository_name: "frontend",
|
||||
path: "frontend/src/shared-search.ts",
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
"bg-slate-500": "#64748b",
|
||||
@@ -185,10 +196,11 @@ function CommandsListContent({
|
||||
)}
|
||||
{search.trim() ? (
|
||||
<CommandGroup heading="Commands">
|
||||
{/* cmdk preserves this priority pre-sort when filter scores tie. */}
|
||||
{sortCommandsForSearch(commands, search).map((cmd) => (
|
||||
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />
|
||||
))}
|
||||
{sortCommandsForSearch(commands, search)
|
||||
.filter((cmd) => scoreCommandSearch(cmd.id, search, getCommandSearchTerms(cmd)) > 0)
|
||||
.map((cmd) => (
|
||||
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : (
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
@@ -220,39 +240,59 @@ function FileSearchContent({ files, isSearching, search, onSelect }: FileSearchC
|
||||
}
|
||||
if (search.trim() && files.length === 0) return <CommandEmpty>No files found.</CommandEmpty>;
|
||||
if (!search.trim()) return <CommandEmpty>Type to search files...</CommandEmpty>;
|
||||
return (
|
||||
<CommandGroup heading="Files" forceMount>
|
||||
{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) => (
|
||||
<CommandGroup
|
||||
key={group.repositoryName || "workspace"}
|
||||
heading={
|
||||
singleRepo
|
||||
? "Files"
|
||||
: (getRepoDisplayName(group.repositoryName) ?? group.repositoryName ?? "Workspace")
|
||||
}
|
||||
forceMount
|
||||
data-testid="file-search-repo-group"
|
||||
data-repository={group.repositoryName}
|
||||
>
|
||||
{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 (
|
||||
<CommandItem
|
||||
key={filePath}
|
||||
value={getFileResultValue(filePath)}
|
||||
onSelect={() => onSelect(filePath)}
|
||||
key={file.path}
|
||||
value={getFileResultValue(file.path)}
|
||||
onSelect={() => onSelect(file.path)}
|
||||
forceMount
|
||||
>
|
||||
<FileIcon fileName={fileName} className="shrink-0" />
|
||||
<span className="font-medium truncate">{fileName}</span>
|
||||
{dir && <span className="text-muted-foreground text-xs truncate ml-1">{dir}</span>}
|
||||
<span className="truncate font-medium">{fileName}</span>
|
||||
{dir && <span className="ml-1 truncate text-xs text-muted-foreground">{dir}</span>}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="border-t border-border px-3 py-1.5 flex items-center gap-3 text-[0.6rem] text-muted-foreground">
|
||||
{mode === MODE_COMMANDS && (
|
||||
{isScopeMode && (
|
||||
<>
|
||||
<KbdGroup>
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>↓</Kbd>
|
||||
<span>Navigate</span>
|
||||
</KbdGroup>
|
||||
<KbdGroup>
|
||||
<Kbd>{formatShortcut(getShortcut("FILE_SEARCH", keyboardShortcuts))}</Kbd>
|
||||
<span>File Search</span>
|
||||
</KbdGroup>
|
||||
{canSwitchScope && (
|
||||
<KbdGroup>
|
||||
<Kbd>Tab</Kbd>
|
||||
<span>Switch mode</span>
|
||||
</KbdGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<KbdGroup>
|
||||
<Kbd>↵</Kbd>
|
||||
<span>{getEnterLabel(mode)}</span>
|
||||
</KbdGroup>
|
||||
{mode !== MODE_COMMANDS && (
|
||||
{!isScopeMode && (
|
||||
<KbdGroup>
|
||||
<Kbd>⌫</Kbd>
|
||||
<span>Back</span>
|
||||
@@ -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 (
|
||||
<div className="flex min-h-10 items-center border-b border-border [&>[data-slot=command-input-wrapper]]:min-w-0 [&>[data-slot=command-input-wrapper]]:flex-1 [&>[data-slot=command-input-wrapper]]:pb-1">
|
||||
{!isTopLevelMode && (
|
||||
<button
|
||||
onClick={goBack}
|
||||
tabIndex={-1}
|
||||
className="shrink-0 pl-2 flex min-h-10 cursor-pointer items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>{modeLabel}</span>
|
||||
<span className="text-muted-foreground/50">›</span>
|
||||
</button>
|
||||
)}
|
||||
<CommandInput
|
||||
placeholder={getInputPlaceholder(mode, inputCommand)}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
onKeyDown={onInputKeyDown}
|
||||
/>
|
||||
{canSwitchScope && <CommandPanelScopeSwitcher mode={mode} onScopeChange={onScopeChange} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<CommandList>
|
||||
{mode === MODE_COMMANDS && (
|
||||
<CommandsListContent
|
||||
commands={commands}
|
||||
grouped={grouped}
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
taskResults={taskResults}
|
||||
isSearching={isSearching}
|
||||
stepMap={stepMap}
|
||||
repoMap={repoMap}
|
||||
onTaskSelect={handleTaskSelect}
|
||||
/>
|
||||
)}
|
||||
{mode === MODE_SEARCH_FILES && (
|
||||
<FileSearchContent
|
||||
files={fileResults}
|
||||
isSearching={isSearchingFiles}
|
||||
search={search}
|
||||
sessionId={activeSessionId}
|
||||
onSelect={handleFileSelect}
|
||||
/>
|
||||
)}
|
||||
{mode === MODE_SEARCH_CONTENT && (
|
||||
<WorkspaceContentSearch
|
||||
results={contentResults}
|
||||
isSearching={isSearchingContent}
|
||||
error={contentSearchError}
|
||||
search={search}
|
||||
sessionId={activeSessionId}
|
||||
onSelect={handleContentSelect}
|
||||
/>
|
||||
)}
|
||||
{mode === "input" &&
|
||||
(!search.trim() ? (
|
||||
<CommandEmpty>{inputCommand?.inputPlaceholder ?? "Enter a value..."}</CommandEmpty>
|
||||
) : (
|
||||
<CommandEmpty>Press Enter to confirm</CommandEmpty>
|
||||
))}
|
||||
</CommandList>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
@@ -353,8 +521,9 @@ export function CommandPanelView({
|
||||
overlayClassName="supports-backdrop-filter:backdrop-blur-none!"
|
||||
>
|
||||
<Command
|
||||
filter={scoreCommandSearch}
|
||||
shouldFilter={mode === MODE_COMMANDS}
|
||||
// Mode changes replace whole group trees. Filtering before render avoids
|
||||
// cmdk's deferred sorter retaining a group after that group unmounts.
|
||||
shouldFilter={false}
|
||||
loop
|
||||
// cmdk's built-in vim bindings intercept Ctrl/Cmd+K, +P, +J, +N as
|
||||
// list-navigation shortcuts and call `event.preventDefault()` on the
|
||||
@@ -371,55 +540,12 @@ export function CommandPanelView({
|
||||
value={selectedValue}
|
||||
onValueChange={setSelectedValue}
|
||||
>
|
||||
<div className="flex items-center border-b border-border [&>[data-slot=command-input-wrapper]]:flex-1">
|
||||
{mode !== MODE_COMMANDS && (
|
||||
<button
|
||||
onClick={goBack}
|
||||
tabIndex={-1}
|
||||
className="shrink-0 pl-2 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>{modeLabel}</span>
|
||||
<span className="text-muted-foreground/50">›</span>
|
||||
</button>
|
||||
)}
|
||||
<CommandInput
|
||||
placeholder={getInputPlaceholder(mode, inputCommand)}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
{mode === MODE_COMMANDS && (
|
||||
<CommandsListContent
|
||||
commands={commands}
|
||||
grouped={grouped}
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
taskResults={taskResults}
|
||||
isSearching={isSearching}
|
||||
stepMap={stepMap}
|
||||
repoMap={repoMap}
|
||||
onTaskSelect={handleTaskSelect}
|
||||
/>
|
||||
)}
|
||||
{mode === MODE_SEARCH_FILES && (
|
||||
<FileSearchContent
|
||||
files={fileResults}
|
||||
isSearching={isSearchingFiles}
|
||||
search={search}
|
||||
onSelect={handleFileSelect}
|
||||
/>
|
||||
)}
|
||||
{mode === "input" &&
|
||||
(!search.trim() ? (
|
||||
<CommandEmpty>{inputCommand?.inputPlaceholder ?? "Enter a value..."}</CommandEmpty>
|
||||
) : (
|
||||
<CommandEmpty>Press Enter to confirm</CommandEmpty>
|
||||
))}
|
||||
</CommandList>
|
||||
<CommandPanelFooter mode={mode} />
|
||||
<CommandPanelInputHeader {...renderedProps} />
|
||||
<CommandPanelResultList {...renderedProps} />
|
||||
<CommandPanelFooter
|
||||
mode={renderedProps.mode}
|
||||
workspaceSearchAvailable={workspaceSearchAvailable}
|
||||
/>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Command palette mode"
|
||||
className="mr-1 flex h-10 shrink-0 items-stretch gap-0.5"
|
||||
>
|
||||
{SCOPE_OPTIONS.map((scope) => {
|
||||
const active = mode === scope.mode;
|
||||
const shortcut = formatShortcut(getShortcut(scope.shortcutId, keyboardShortcuts));
|
||||
return (
|
||||
<button
|
||||
key={scope.mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-label={scope.label}
|
||||
aria-selected={active}
|
||||
tabIndex={-1}
|
||||
title={`${scope.label} (${shortcut})`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onScopeChange(scope.mode)}
|
||||
className={cn(
|
||||
"relative flex h-10 cursor-pointer items-center px-2 text-[0.6875rem] font-medium text-muted-foreground outline-none after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:origin-center after:rounded-full after:bg-foreground/70 after:transition-[opacity,scale] after:duration-150 after:ease-out transition-[color,scale] duration-150 ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50 active:scale-[0.96]",
|
||||
active
|
||||
? "text-foreground after:scale-100 after:opacity-100"
|
||||
: "after:scale-x-75 after:opacity-0",
|
||||
)}
|
||||
>
|
||||
<span>{scope.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<CommandPanelMode>(MODE_COMMANDS);
|
||||
function useCommandPanelState(mode: CommandPanelMode, setMode: (mode: CommandPanelMode) => void) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [inputCommand, setInputCommand] = useState<CommandItemType | null>(null);
|
||||
const [taskResults, setTaskResults] = useState<Task[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [fileResults, setFileResults] = useState<string[]>([]);
|
||||
const [fileResults, setFileResults] = useState<FileSearchResult[]>([]);
|
||||
const [isSearchingFiles, setIsSearchingFiles] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState("");
|
||||
return {
|
||||
@@ -57,27 +62,33 @@ function useCommandPanelState() {
|
||||
};
|
||||
}
|
||||
|
||||
function useCommandPanelEffectRefs() {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fileDebounceRef = useRef<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | 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<typeof useCommandPanelState>,
|
||||
workspaceId: string | null,
|
||||
activeSessionId: string | null,
|
||||
steps: { id: string; position: number; show_in_command_panel?: boolean }[],
|
||||
) {
|
||||
type CommandPanelEffectsOptions = {
|
||||
open: boolean;
|
||||
state: ReturnType<typeof useCommandPanelState>;
|
||||
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<ReturnType<typeof setTimeout> | 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<typeof useCommandPanelState>,
|
||||
commands: CommandItemType[],
|
||||
contentResults: ReturnType<typeof useWorkspaceContentSearch>["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 (
|
||||
<CommandPanelView
|
||||
@@ -519,10 +590,17 @@ export function CommandPanel() {
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
handleKeyDown={handleKeyDown}
|
||||
onScopeChange={onScopeChange}
|
||||
goBack={goBack}
|
||||
fileResults={fileResults}
|
||||
isSearchingFiles={isSearchingFiles}
|
||||
handleFileSelect={handleFileSelect}
|
||||
contentResults={contentResults}
|
||||
isSearchingContent={isSearchingContent}
|
||||
contentSearchError={contentSearchError}
|
||||
activeSessionId={activeSessionId}
|
||||
workspaceSearchAvailable={workspaceSearchAvailable}
|
||||
handleContentSelect={handleContentSelect}
|
||||
commands={commands}
|
||||
grouped={grouped}
|
||||
handleSelect={handleSelect}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
consumePendingCursorPosition,
|
||||
scrollEditorIfMounted,
|
||||
setPendingCursorPosition,
|
||||
} from "@/hooks/file-editor-cursor";
|
||||
|
||||
const editorHarness = vi.hoisted(() => ({
|
||||
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 <div data-testid="codemirror" />;
|
||||
},
|
||||
}));
|
||||
|
||||
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(
|
||||
<TooltipProvider>
|
||||
<CodeMirrorCodeEditor {...props} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<TooltipProvider>
|
||||
<CodeMirrorCodeEditor {...baseProps} isDirty />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }) => <div>{children}</div>,
|
||||
CommandGroup: ({
|
||||
children,
|
||||
heading,
|
||||
forceMount: _forceMount,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode;
|
||||
heading: ReactNode;
|
||||
forceMount?: boolean;
|
||||
}) => (
|
||||
<section {...props}>
|
||||
<h2>{heading}</h2>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
CommandItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
forceMount: _forceMount,
|
||||
value: _value,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onSelect: () => void;
|
||||
forceMount?: boolean;
|
||||
value?: string;
|
||||
}) => (
|
||||
<button {...props} onClick={onSelect}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
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 }) => <span aria-label={`file ${fileName}`} />,
|
||||
}));
|
||||
|
||||
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(
|
||||
<WorkspaceContentSearch
|
||||
results={results}
|
||||
isSearching={false}
|
||||
error={null}
|
||||
search="needle"
|
||||
sessionId="session-1"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<WorkspaceContentSearch
|
||||
results={[{ ...results[0], repository_name: "" }]}
|
||||
isSearching={false}
|
||||
error={null}
|
||||
search="needle"
|
||||
sessionId="session-1"
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<WorkspaceContentSearch
|
||||
results={[]}
|
||||
isSearching={false}
|
||||
error={null}
|
||||
search=""
|
||||
sessionId="session-1"
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Type to search task contents…")).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<WorkspaceContentSearch
|
||||
results={[]}
|
||||
isSearching
|
||||
error={null}
|
||||
search="needle"
|
||||
sessionId="session-1"
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Searching task workspace…")).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<WorkspaceContentSearch
|
||||
results={[]}
|
||||
isSearching={false}
|
||||
error={null}
|
||||
search="needle"
|
||||
sessionId="session-1"
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<WorkspaceContentSearch
|
||||
results={[]}
|
||||
isSearching={false}
|
||||
error={error}
|
||||
search="needle"
|
||||
sessionId={error === "session-unavailable" ? null : "session-1"}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(message)).toBeTruthy();
|
||||
expect(screen.queryByText("No content matches found.")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<span
|
||||
data-testid="content-search-preview"
|
||||
className="block truncate font-mono text-[0.6875rem] leading-4 text-muted-foreground"
|
||||
>
|
||||
{splitPreviewByMatches(result.preview, result.match_ranges).map((part, index) =>
|
||||
part.matched ? (
|
||||
<mark
|
||||
key={`${index}:${part.text}`}
|
||||
data-testid="content-search-match"
|
||||
className="rounded-[2px] bg-primary/15 px-0 text-foreground"
|
||||
>
|
||||
{part.text}
|
||||
</mark>
|
||||
) : (
|
||||
part.text
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultRow({
|
||||
result,
|
||||
onSelect,
|
||||
}: {
|
||||
result: WorkspaceContentSearchResult;
|
||||
onSelect: (result: WorkspaceContentSearchResult) => void;
|
||||
}) {
|
||||
return (
|
||||
<CommandItem
|
||||
value={getContentSearchResultValue(result)}
|
||||
onSelect={() => 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"
|
||||
>
|
||||
<FileIcon fileName={getFileName(result.path)} className="mt-0.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="truncate font-medium">{result.path}</span>
|
||||
<span className="ml-auto shrink-0 font-mono text-[0.625rem] text-muted-foreground tabular-nums">
|
||||
{result.line}
|
||||
</span>
|
||||
</span>
|
||||
<HighlightedPreview result={result} />
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
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 <CommandEmpty>Content search needs an active task session.</CommandEmpty>;
|
||||
}
|
||||
if (error === "query-too-long") {
|
||||
return <CommandEmpty>Search queries are limited to 200 characters.</CommandEmpty>;
|
||||
}
|
||||
if (error === "transport-error") {
|
||||
return <CommandEmpty>Search failed. Edit the query or reopen search to retry.</CommandEmpty>;
|
||||
}
|
||||
if (isSearching && results.length === 0) {
|
||||
return (
|
||||
<CommandEmpty>
|
||||
<IconLoader2 className="mr-2 inline size-3.5 animate-spin text-muted-foreground" />
|
||||
Searching task workspace…
|
||||
</CommandEmpty>
|
||||
);
|
||||
}
|
||||
if (!search.trim()) return <CommandEmpty>Type to search task contents…</CommandEmpty>;
|
||||
if (results.length === 0) return <CommandEmpty>No content matches found.</CommandEmpty>;
|
||||
|
||||
const groups = groupByRepositoryName(results, (result) => result.repository_name);
|
||||
const singleRepo = isSingleRepoGroup(groups);
|
||||
return groups.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.repositoryName}
|
||||
heading={singleRepo ? "Results" : (getRepoDisplayName(group.repositoryName) ?? "Workspace")}
|
||||
forceMount
|
||||
data-testid="content-search-repo-group"
|
||||
data-repository={group.repositoryName}
|
||||
>
|
||||
{group.items.map((result) => (
|
||||
<SearchResultRow
|
||||
key={getContentSearchResultValue(result)}
|
||||
result={result}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
));
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<WorkspaceContentSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [error, setError] = useState<WorkspaceContentSearchError | null>(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 };
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { walkthroughFileMatches } from "@/lib/diff/walkthrough-match";
|
||||
import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions";
|
||||
|
||||
const pendingCursorPositions = new Map<string, { line: number; column: number }>();
|
||||
type CodeMirrorCursorRevealer = (line: number, column: number) => boolean;
|
||||
const codeMirrorCursorRevealers = new Map<string, Set<CodeMirrorCursorRevealer>>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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] } });
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
|
||||
@@ -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 <CommandRegistryProvider>{children}</CommandRegistryProvider>;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<CommandRegistryContextValue | null>(null);
|
||||
@@ -27,6 +32,14 @@ export function CommandRegistryProvider({ children }: { children: ReactNode }) {
|
||||
const listenersRef = useRef(new Set<() => void>());
|
||||
const snapshotRef = useRef<CommandItem[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<CommandPanelMode>("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 (
|
||||
<CommandRegistryContext.Provider
|
||||
value={{ register, unregister, subscribe, getSnapshot, open, setOpen }}
|
||||
value={{
|
||||
register,
|
||||
unregister,
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
open,
|
||||
setOpen,
|
||||
mode,
|
||||
setMode,
|
||||
openMode,
|
||||
modeRequestVersion,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CommandRegistryContext.Provider>
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<WorkspaceContentSearchResponse> {
|
||||
return client.request<WorkspaceContentSearchResponse>("workspace.content.search", {
|
||||
session_id: sessionId,
|
||||
query,
|
||||
limit_per_repo: limitPerRepo,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* File update response from backend
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user