feat(search): add task workspace search palette (#2006)

This commit is contained in:
José Almeida
2026-07-29 09:50:29 +01:00
committed by GitHub
parent e8d4b7d69e
commit 7ec82dcaec
54 changed files with 4444 additions and 242 deletions
@@ -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
+17 -10
View File
@@ -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
+1
View File
@@ -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");
});
});
+224 -98
View File
@@ -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>
);
}
+143 -65
View File
@@ -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>
));
}
+48 -12
View File
@@ -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 };
}
+63 -17
View File
@@ -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);
}
+112
View File
@@ -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);
});
});
+15 -2
View File
@@ -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],
);
}
+28
View File
@@ -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] } });
+20 -8
View File
@@ -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);
});
});
+27 -3
View File
@@ -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;
}
+6 -1
View File
@@ -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;
+4
View File
@@ -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 },
+29
View File
@@ -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,
});
});
});
+15
View File
@@ -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
*/
+14
View File
@@ -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.
+1
View File
@@ -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.