829 lines
22 KiB
Go
829 lines
22 KiB
Go
package bot
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/secrets"
|
|
)
|
|
|
|
const (
|
|
botProjectListLimit = 20
|
|
botSessionListLimit = 20
|
|
botSearchListLimit = 20
|
|
)
|
|
|
|
type botProjectEntry struct {
|
|
ID string
|
|
Name string
|
|
Root string
|
|
Sources []string
|
|
}
|
|
|
|
type botSessionEntry struct {
|
|
ID string
|
|
ProjectID string
|
|
ProjectName string
|
|
WorkspaceRoot string
|
|
SessionID string
|
|
SessionPath string
|
|
RemoteID string
|
|
ConnectionID string
|
|
ChatType string
|
|
UserID string
|
|
ThreadID string
|
|
Scope string
|
|
Preview string
|
|
TopicTitle string
|
|
LastActivityAt time.Time
|
|
Source string
|
|
}
|
|
|
|
type botProjectSearchResult struct {
|
|
ProjectID string
|
|
ProjectName string
|
|
Path string
|
|
Line int
|
|
Text string
|
|
}
|
|
|
|
func (gw *BotGateway) buildProjectIndex() []botProjectEntry {
|
|
collector := newBotProjectCollector()
|
|
collector.add(gw.cfg.WorkspaceRoot, "default")
|
|
|
|
// cfg.Channels / cfg.ConnectionChannels are rewritten under gw.mu at runtime,
|
|
// so the whole scan shares the controllers critical section below.
|
|
gw.mu.Lock()
|
|
platforms := make([]string, 0, len(gw.cfg.Channels))
|
|
for platform := range gw.cfg.Channels {
|
|
platforms = append(platforms, string(platform))
|
|
}
|
|
sort.Strings(platforms)
|
|
for _, platform := range platforms {
|
|
channel := gw.cfg.Channels[Platform(platform)]
|
|
source := "channel:" + platform
|
|
collector.add(channel.WorkspaceRoot, source)
|
|
addMappingProjects(collector, channel, source)
|
|
}
|
|
|
|
connections := make([]string, 0, len(gw.cfg.ConnectionChannels))
|
|
for id := range gw.cfg.ConnectionChannels {
|
|
connections = append(connections, id)
|
|
}
|
|
sort.Strings(connections)
|
|
for _, id := range connections {
|
|
channel := gw.cfg.ConnectionChannels[id]
|
|
source := "connection:" + id
|
|
collector.add(channel.WorkspaceRoot, source)
|
|
addMappingProjects(collector, channel, source)
|
|
}
|
|
|
|
for i, route := range gw.cfg.Routes {
|
|
collector.add(route.Channel.WorkspaceRoot, fmt.Sprintf("route:%d", i+1))
|
|
}
|
|
|
|
for key, state := range gw.controllers {
|
|
root := ""
|
|
if state != nil {
|
|
root = state.workspaceRoot
|
|
if root == "" && state.ctrl != nil {
|
|
root = state.ctrl.WorkspaceRoot()
|
|
}
|
|
}
|
|
collector.add(root, "active:"+shortBotID(key))
|
|
}
|
|
for key, override := range gw.sessionOverrides {
|
|
collector.add(override.channel.WorkspaceRoot, "override:"+shortBotID(key))
|
|
}
|
|
gw.mu.Unlock()
|
|
|
|
return collector.entries()
|
|
}
|
|
|
|
func addMappingProjects(collector *botProjectCollector, channel ChannelConfig, source string) {
|
|
for _, mapping := range channel.SessionMappings {
|
|
root := workspaceRootForSessionMapping(mapping, channel.WorkspaceRoot)
|
|
collector.add(root, source+":mapping:"+strings.TrimSpace(mapping.RemoteID))
|
|
}
|
|
}
|
|
|
|
type botProjectCollector struct {
|
|
byRoot map[string]*botProjectEntry
|
|
}
|
|
|
|
func newBotProjectCollector() *botProjectCollector {
|
|
return &botProjectCollector{byRoot: make(map[string]*botProjectEntry)}
|
|
}
|
|
|
|
func (c *botProjectCollector) add(root, source string) {
|
|
root = canonicalBotPath(root)
|
|
if root == "" {
|
|
return
|
|
}
|
|
entry := c.byRoot[root]
|
|
if entry == nil {
|
|
entry = &botProjectEntry{Name: botProjectName(root), Root: root}
|
|
c.byRoot[root] = entry
|
|
}
|
|
source = strings.TrimSpace(source)
|
|
if source == "" {
|
|
return
|
|
}
|
|
for _, existing := range entry.Sources {
|
|
if existing == source {
|
|
return
|
|
}
|
|
}
|
|
entry.Sources = append(entry.Sources, source)
|
|
}
|
|
|
|
func (c *botProjectCollector) entries() []botProjectEntry {
|
|
out := make([]botProjectEntry, 0, len(c.byRoot))
|
|
for _, entry := range c.byRoot {
|
|
copied := *entry
|
|
sort.Strings(copied.Sources)
|
|
out = append(out, copied)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
li := strings.ToLower(out[i].Name)
|
|
lj := strings.ToLower(out[j].Name)
|
|
if li != lj {
|
|
return li < lj
|
|
}
|
|
return out[i].Root < out[j].Root
|
|
})
|
|
for i := range out {
|
|
out[i].ID = fmt.Sprintf("p%d", i+1)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (gw *BotGateway) buildSessionIndex(projects []botProjectEntry) []botSessionEntry {
|
|
projectByRoot := make(map[string]botProjectEntry, len(projects))
|
|
for _, project := range projects {
|
|
projectByRoot[canonicalBotPath(project.Root)] = project
|
|
}
|
|
collector := newBotSessionCollector(projectByRoot)
|
|
|
|
// cfg.Channels / cfg.ConnectionChannels are rewritten under gw.mu at runtime;
|
|
// collect the mapping-derived entries under a short lock, keeping the
|
|
// filesystem scan below outside it.
|
|
gw.mu.Lock()
|
|
platforms := make([]string, 0, len(gw.cfg.Channels))
|
|
for platform := range gw.cfg.Channels {
|
|
platforms = append(platforms, string(platform))
|
|
}
|
|
sort.Strings(platforms)
|
|
for _, platform := range platforms {
|
|
channel := gw.cfg.Channels[Platform(platform)]
|
|
addMappingSessions(collector, channel, "", "channel:"+platform)
|
|
}
|
|
|
|
connections := make([]string, 0, len(gw.cfg.ConnectionChannels))
|
|
for id := range gw.cfg.ConnectionChannels {
|
|
connections = append(connections, id)
|
|
}
|
|
sort.Strings(connections)
|
|
for _, id := range connections {
|
|
channel := gw.cfg.ConnectionChannels[id]
|
|
addMappingSessions(collector, channel, id, "connection:"+id)
|
|
}
|
|
gw.mu.Unlock()
|
|
|
|
for _, project := range projects {
|
|
dir := botSessionDir(project.Root)
|
|
if dir == "" {
|
|
continue
|
|
}
|
|
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
|
continue
|
|
}
|
|
infos, err := agent.ListSessions(dir)
|
|
if err != nil {
|
|
gw.logger.Warn("bot project session index failed", "project", project.Name, "err", err)
|
|
continue
|
|
}
|
|
for _, info := range infos {
|
|
collector.add(botSessionEntry{
|
|
WorkspaceRoot: project.Root,
|
|
SessionPath: canonicalBotPath(info.Path),
|
|
SessionID: botSessionTarget(info.Path),
|
|
Scope: firstNonEmptyString(info.Scope, "project"),
|
|
Preview: info.Preview,
|
|
TopicTitle: info.TopicTitle,
|
|
LastActivityAt: info.LastActivityAt,
|
|
Source: "project-sessions",
|
|
})
|
|
}
|
|
}
|
|
|
|
return collector.entries()
|
|
}
|
|
|
|
func addMappingSessions(collector *botSessionCollector, channel ChannelConfig, connectionID, source string) {
|
|
for _, mapping := range channel.SessionMappings {
|
|
sessionID := strings.TrimSpace(mapping.SessionID)
|
|
sessionPath := botSessionPathFromTarget(sessionID)
|
|
root := workspaceRootForSessionMapping(mapping, channel.WorkspaceRoot)
|
|
collector.add(botSessionEntry{
|
|
WorkspaceRoot: root,
|
|
SessionID: sessionID,
|
|
SessionPath: sessionPath,
|
|
RemoteID: strings.TrimSpace(mapping.RemoteID),
|
|
ConnectionID: strings.TrimSpace(connectionID),
|
|
ChatType: strings.TrimSpace(mapping.ChatType),
|
|
UserID: strings.TrimSpace(mapping.UserID),
|
|
ThreadID: strings.TrimSpace(mapping.ThreadID),
|
|
Scope: strings.TrimSpace(mapping.Scope),
|
|
LastActivityAt: parseBotMappingUpdatedAt(mapping.UpdatedAt),
|
|
Source: source,
|
|
})
|
|
}
|
|
}
|
|
|
|
type botSessionCollector struct {
|
|
projectByRoot map[string]botProjectEntry
|
|
byKey map[string]*botSessionEntry
|
|
}
|
|
|
|
func newBotSessionCollector(projectByRoot map[string]botProjectEntry) *botSessionCollector {
|
|
return &botSessionCollector{
|
|
projectByRoot: projectByRoot,
|
|
byKey: make(map[string]*botSessionEntry),
|
|
}
|
|
}
|
|
|
|
func (c *botSessionCollector) add(entry botSessionEntry) {
|
|
entry.WorkspaceRoot = canonicalBotPath(entry.WorkspaceRoot)
|
|
entry.SessionPath = canonicalBotPath(entry.SessionPath)
|
|
if entry.SessionID == "" && entry.SessionPath != "" {
|
|
entry.SessionID = botSessionTarget(entry.SessionPath)
|
|
}
|
|
if entry.SessionPath == "" && entry.SessionID == "" && entry.RemoteID == "" {
|
|
return
|
|
}
|
|
if project, ok := c.projectByRoot[entry.WorkspaceRoot]; ok {
|
|
entry.ProjectID = project.ID
|
|
entry.ProjectName = project.Name
|
|
}
|
|
key := entry.SessionPath
|
|
if key == "" {
|
|
key = strings.Join([]string{"target", entry.ConnectionID, entry.RemoteID, entry.ChatType, entry.UserID, entry.ThreadID, entry.SessionID}, "\x00")
|
|
}
|
|
existing := c.byKey[key]
|
|
if existing == nil {
|
|
c.byKey[key] = &entry
|
|
return
|
|
}
|
|
mergeBotSessionEntry(existing, entry)
|
|
}
|
|
|
|
func mergeBotSessionEntry(dst *botSessionEntry, src botSessionEntry) {
|
|
if dst.ProjectID == "" {
|
|
dst.ProjectID = src.ProjectID
|
|
}
|
|
if dst.ProjectName == "" {
|
|
dst.ProjectName = src.ProjectName
|
|
}
|
|
if dst.WorkspaceRoot == "" {
|
|
dst.WorkspaceRoot = src.WorkspaceRoot
|
|
}
|
|
if dst.SessionID == "" {
|
|
dst.SessionID = src.SessionID
|
|
}
|
|
if dst.SessionPath == "" {
|
|
dst.SessionPath = src.SessionPath
|
|
}
|
|
if dst.RemoteID == "" {
|
|
dst.RemoteID = src.RemoteID
|
|
}
|
|
if dst.ConnectionID == "" {
|
|
dst.ConnectionID = src.ConnectionID
|
|
}
|
|
if dst.ChatType == "" {
|
|
dst.ChatType = src.ChatType
|
|
}
|
|
if dst.UserID == "" {
|
|
dst.UserID = src.UserID
|
|
}
|
|
if dst.ThreadID == "" {
|
|
dst.ThreadID = src.ThreadID
|
|
}
|
|
if dst.Scope == "" {
|
|
dst.Scope = src.Scope
|
|
}
|
|
if dst.Preview == "" {
|
|
dst.Preview = src.Preview
|
|
}
|
|
if dst.TopicTitle == "" {
|
|
dst.TopicTitle = src.TopicTitle
|
|
}
|
|
if src.LastActivityAt.After(dst.LastActivityAt) {
|
|
dst.LastActivityAt = src.LastActivityAt
|
|
}
|
|
if dst.Source == "" {
|
|
dst.Source = src.Source
|
|
} else if src.Source != "" && !strings.Contains(dst.Source, src.Source) {
|
|
dst.Source += "," + src.Source
|
|
}
|
|
}
|
|
|
|
func (c *botSessionCollector) entries() []botSessionEntry {
|
|
out := make([]botSessionEntry, 0, len(c.byKey))
|
|
for _, entry := range c.byKey {
|
|
out = append(out, *entry)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if !out[i].LastActivityAt.Equal(out[j].LastActivityAt) {
|
|
return out[i].LastActivityAt.After(out[j].LastActivityAt)
|
|
}
|
|
if out[i].ProjectName != out[j].ProjectName {
|
|
return out[i].ProjectName < out[j].ProjectName
|
|
}
|
|
return out[i].SessionPath < out[j].SessionPath
|
|
})
|
|
for i := range out {
|
|
out[i].ID = fmt.Sprintf("s%d", i+1)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func botSessionPathFromTarget(target string) string {
|
|
target = strings.TrimSpace(target)
|
|
if target == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(target, "path:") {
|
|
return canonicalBotPath(strings.TrimPrefix(target, "path:"))
|
|
}
|
|
if filepath.IsAbs(target) && strings.HasSuffix(target, ".jsonl") {
|
|
return canonicalBotPath(target)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseBotMappingUpdatedAt(value string) time.Time {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return time.Time{}
|
|
}
|
|
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
|
return t
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
|
return t
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func resolveBotProject(projects []botProjectEntry, selector string) (botProjectEntry, []botProjectEntry) {
|
|
selector = strings.TrimSpace(selector)
|
|
if selector == "" {
|
|
return botProjectEntry{}, nil
|
|
}
|
|
selectorLower := strings.ToLower(selector)
|
|
canonicalSelector := canonicalBotPath(selector)
|
|
var matches []botProjectEntry
|
|
for _, project := range projects {
|
|
if strings.EqualFold(project.ID, selector) || canonicalBotPath(project.Root) == canonicalSelector || strings.EqualFold(project.Name, selector) {
|
|
return project, nil
|
|
}
|
|
if strings.Contains(strings.ToLower(project.Name), selectorLower) || strings.Contains(strings.ToLower(project.Root), selectorLower) {
|
|
matches = append(matches, project)
|
|
}
|
|
}
|
|
if len(matches) == 1 {
|
|
return matches[0], nil
|
|
}
|
|
return botProjectEntry{}, matches
|
|
}
|
|
|
|
func resolveBotSession(sessions []botSessionEntry, selector string) (botSessionEntry, []botSessionEntry) {
|
|
selector = strings.TrimSpace(selector)
|
|
if selector == "" {
|
|
return botSessionEntry{}, nil
|
|
}
|
|
selectorLower := strings.ToLower(selector)
|
|
canonicalSelector := canonicalBotPath(selector)
|
|
var matches []botSessionEntry
|
|
for _, session := range sessions {
|
|
if strings.EqualFold(session.ID, selector) ||
|
|
(session.SessionPath != "" && canonicalBotPath(session.SessionPath) == canonicalSelector) ||
|
|
(session.SessionPath != "" && strings.EqualFold(filepath.Base(session.SessionPath), selector)) ||
|
|
(session.SessionID != "" && strings.EqualFold(session.SessionID, selector)) {
|
|
return session, nil
|
|
}
|
|
if botSessionMatchesQuery(session, selectorLower) {
|
|
matches = append(matches, session)
|
|
}
|
|
}
|
|
if len(matches) == 1 {
|
|
return matches[0], nil
|
|
}
|
|
return botSessionEntry{}, matches
|
|
}
|
|
|
|
func filterBotProjects(projects []botProjectEntry, query string) []botProjectEntry {
|
|
query = strings.ToLower(strings.TrimSpace(query))
|
|
if query == "" {
|
|
return projects
|
|
}
|
|
var out []botProjectEntry
|
|
for _, project := range projects {
|
|
if strings.Contains(strings.ToLower(project.Name+" "+project.Root+" "+strings.Join(project.Sources, " ")), query) {
|
|
out = append(out, project)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func filterBotSessions(sessions []botSessionEntry, query string) []botSessionEntry {
|
|
query = strings.ToLower(strings.TrimSpace(query))
|
|
if query == "" {
|
|
return sessions
|
|
}
|
|
var out []botSessionEntry
|
|
for _, session := range sessions {
|
|
if botSessionMatchesQuery(session, query) {
|
|
out = append(out, session)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func botSessionMatchesQuery(session botSessionEntry, query string) bool {
|
|
haystack := strings.ToLower(strings.Join([]string{
|
|
session.ID,
|
|
session.ProjectID,
|
|
session.ProjectName,
|
|
session.WorkspaceRoot,
|
|
session.SessionID,
|
|
session.SessionPath,
|
|
session.RemoteID,
|
|
session.ConnectionID,
|
|
session.ChatType,
|
|
session.UserID,
|
|
session.ThreadID,
|
|
session.Scope,
|
|
session.Preview,
|
|
session.TopicTitle,
|
|
session.Source,
|
|
}, " "))
|
|
return strings.Contains(haystack, query)
|
|
}
|
|
|
|
func formatBotProjects(projects []botProjectEntry, query string, limit int) string {
|
|
matches := filterBotProjects(projects, query)
|
|
if len(matches) == 0 {
|
|
if strings.TrimSpace(query) == "" {
|
|
return "还没有可用项目索引。请先在 bot 连接、route 或当前会话里配置 workspace_root。"
|
|
}
|
|
return "没有匹配的项目。"
|
|
}
|
|
if limit <= 0 || limit > len(matches) {
|
|
limit = len(matches)
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "项目索引(%d/%d):", limit, len(matches))
|
|
for i := 0; i < limit; i++ {
|
|
project := matches[i]
|
|
fmt.Fprintf(&b, "\n%s %s — %s", project.ID, project.Name, displayBotPath(project.Root))
|
|
if len(project.Sources) > 0 {
|
|
fmt.Fprintf(&b, "\n 来源: %s", strings.Join(project.Sources, ", "))
|
|
}
|
|
}
|
|
if len(matches) > limit {
|
|
fmt.Fprintf(&b, "\n还有 %d 个结果,请加关键词缩小范围。", len(matches)-limit)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func formatBotSessions(sessions []botSessionEntry, query string, limit int) string {
|
|
matches := filterBotSessions(sessions, query)
|
|
if len(matches) == 0 {
|
|
if strings.TrimSpace(query) == "" {
|
|
return "还没有可用会话索引。已有项目会话或 bot session_mappings 后会出现在这里。"
|
|
}
|
|
return "没有匹配的会话。"
|
|
}
|
|
if limit <= 0 || limit > len(matches) {
|
|
limit = len(matches)
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "会话索引(%d/%d):", limit, len(matches))
|
|
for i := 0; i < limit; i++ {
|
|
session := matches[i]
|
|
project := firstNonEmptyString(session.ProjectName, "global")
|
|
fmt.Fprintf(&b, "\n%s %s", session.ID, project)
|
|
if session.TopicTitle != "" {
|
|
fmt.Fprintf(&b, " · %s", singleLineBotText(session.TopicTitle, 40))
|
|
}
|
|
if session.Preview != "" {
|
|
fmt.Fprintf(&b, "\n 预览: %s", singleLineBotText(session.Preview, 90))
|
|
}
|
|
if session.SessionPath != "" {
|
|
fmt.Fprintf(&b, "\n 文件: %s", displayBotPath(session.SessionPath))
|
|
} else if session.SessionID != "" {
|
|
fmt.Fprintf(&b, "\n 目标: %s", session.SessionID)
|
|
}
|
|
if session.RemoteID != "" || session.ConnectionID != "" {
|
|
fmt.Fprintf(&b, "\n 远端: %s %s", session.ConnectionID, session.RemoteID)
|
|
}
|
|
}
|
|
if len(matches) > limit {
|
|
fmt.Fprintf(&b, "\n还有 %d 个结果,请加关键词缩小范围。", len(matches)-limit)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func formatBotProjectSearchResults(results []botProjectSearchResult, limit int) string {
|
|
if len(results) == 0 {
|
|
return "没有跨项目命中。"
|
|
}
|
|
if limit <= 0 || limit > len(results) {
|
|
limit = len(results)
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "跨项目检索结果(%d/%d):", limit, len(results))
|
|
for i := 0; i < limit; i++ {
|
|
result := results[i]
|
|
project := firstNonEmptyString(result.ProjectName, result.ProjectID)
|
|
fmt.Fprintf(&b, "\n- %s %s:%d: %s", project, displayBotPath(result.Path), result.Line, singleLineBotText(result.Text, 120))
|
|
}
|
|
if len(results) > limit {
|
|
fmt.Fprintf(&b, "\n还有 %d 条命中,请加关键词缩小范围。", len(results)-limit)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func searchBotProjects(ctx context.Context, projects []botProjectEntry, query string, limit int) ([]botProjectSearchResult, error) {
|
|
query = strings.TrimSpace(query)
|
|
if len([]rune(query)) < 2 {
|
|
return nil, errors.New("检索词至少需要 2 个字符")
|
|
}
|
|
var roots []string
|
|
seen := map[string]bool{}
|
|
for _, project := range projects {
|
|
root := canonicalBotPath(project.Root)
|
|
if root == "" || seen[root] {
|
|
continue
|
|
}
|
|
info, err := os.Stat(root)
|
|
if err != nil || !info.IsDir() {
|
|
continue
|
|
}
|
|
seen[root] = true
|
|
roots = append(roots, root)
|
|
}
|
|
if len(roots) == 0 {
|
|
return nil, errors.New("没有可检索的项目目录")
|
|
}
|
|
if limit <= 0 {
|
|
limit = botSearchListLimit
|
|
}
|
|
if rg, err := exec.LookPath("rg"); err == nil {
|
|
return searchBotProjectsWithRG(ctx, rg, projects, roots, query, limit)
|
|
}
|
|
return searchBotProjectsFallback(ctx, projects, roots, query, limit)
|
|
}
|
|
|
|
func searchBotProjectsWithRG(ctx context.Context, rg string, projects []botProjectEntry, roots []string, query string, limit int) ([]botProjectSearchResult, error) {
|
|
args := []string{
|
|
"--json",
|
|
"--color", "never",
|
|
"--fixed-strings",
|
|
"--max-count", "3",
|
|
"--max-filesize", "1M",
|
|
"--glob", "!.git",
|
|
"--glob", "!node_modules",
|
|
"--glob", "!dist",
|
|
"--glob", "!build",
|
|
"--glob", "!vendor",
|
|
"--",
|
|
query,
|
|
}
|
|
args = append(args, roots...)
|
|
cmd := exec.CommandContext(ctx, rg, args...)
|
|
cmd.Env = secrets.ProcessEnv()
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("rg failed: %w", err)
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(out))
|
|
var results []botProjectSearchResult
|
|
for {
|
|
var item struct {
|
|
Type string `json:"type"`
|
|
Data struct {
|
|
Path struct {
|
|
Text string `json:"text"`
|
|
} `json:"path"`
|
|
Lines struct {
|
|
Text string `json:"text"`
|
|
} `json:"lines"`
|
|
LineNumber int `json:"line_number"`
|
|
} `json:"data"`
|
|
}
|
|
if err := dec.Decode(&item); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
break
|
|
}
|
|
if item.Type != "match" {
|
|
continue
|
|
}
|
|
path := canonicalBotPath(item.Data.Path.Text)
|
|
project := botProjectForPath(projects, path)
|
|
results = append(results, botProjectSearchResult{
|
|
ProjectID: project.ID,
|
|
ProjectName: project.Name,
|
|
Path: path,
|
|
Line: item.Data.LineNumber,
|
|
Text: strings.TrimSpace(item.Data.Lines.Text),
|
|
})
|
|
if len(results) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
var errStopBotSearch = errors.New("stop bot project search")
|
|
|
|
func searchBotProjectsFallback(ctx context.Context, projects []botProjectEntry, roots []string, query string, limit int) ([]botProjectSearchResult, error) {
|
|
queryLower := strings.ToLower(query)
|
|
var results []botProjectSearchResult
|
|
for _, root := range roots {
|
|
if err := ctx.Err(); err != nil {
|
|
return results, err
|
|
}
|
|
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
if shouldSkipBotSearchDir(d.Name()) && path != root {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil || info.Size() > 1024*1024 {
|
|
return nil
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
scanner := bufio.NewScanner(file)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
line := 0
|
|
for scanner.Scan() {
|
|
if err := ctx.Err(); err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
line++
|
|
text := scanner.Text()
|
|
if strings.Contains(strings.ToLower(text), queryLower) {
|
|
project := botProjectForPath(projects, path)
|
|
results = append(results, botProjectSearchResult{
|
|
ProjectID: project.ID,
|
|
ProjectName: project.Name,
|
|
Path: canonicalBotPath(path),
|
|
Line: line,
|
|
Text: strings.TrimSpace(text),
|
|
})
|
|
if len(results) >= limit {
|
|
_ = file.Close()
|
|
return errStopBotSearch
|
|
}
|
|
}
|
|
}
|
|
_ = file.Close()
|
|
return nil
|
|
})
|
|
if errors.Is(walkErr, errStopBotSearch) {
|
|
break
|
|
}
|
|
if walkErr != nil {
|
|
return results, walkErr
|
|
}
|
|
if len(results) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func shouldSkipBotSearchDir(name string) bool {
|
|
switch name {
|
|
case ".git", "node_modules", "dist", "build", "vendor", ".next", ".cache":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func botProjectForPath(projects []botProjectEntry, path string) botProjectEntry {
|
|
path = canonicalBotPath(path)
|
|
var best botProjectEntry
|
|
for _, project := range projects {
|
|
root := canonicalBotPath(project.Root)
|
|
if root == "" {
|
|
continue
|
|
}
|
|
if path == root || strings.HasPrefix(path, root+string(os.PathSeparator)) {
|
|
if len(root) > len(best.Root) {
|
|
best = project
|
|
}
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func canonicalBotPath(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
if abs, err := filepath.Abs(path); err == nil {
|
|
path = abs
|
|
}
|
|
return filepath.Clean(path)
|
|
}
|
|
|
|
func botProjectName(root string) string {
|
|
root = strings.TrimRight(canonicalBotPath(root), string(os.PathSeparator))
|
|
if root == "" {
|
|
return ""
|
|
}
|
|
name := filepath.Base(root)
|
|
if name == "." || name == string(os.PathSeparator) {
|
|
return root
|
|
}
|
|
return name
|
|
}
|
|
|
|
func displayBotPath(path string) string {
|
|
path = canonicalBotPath(path)
|
|
home, err := os.UserHomeDir()
|
|
if err == nil {
|
|
home = canonicalBotPath(home)
|
|
if home != "" && (path == home || strings.HasPrefix(path, home+string(os.PathSeparator))) {
|
|
return "~" + strings.TrimPrefix(path, home)
|
|
}
|
|
}
|
|
return path
|
|
}
|
|
|
|
func singleLineBotText(text string, limit int) string {
|
|
text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
|
|
if limit <= 0 {
|
|
return text
|
|
}
|
|
runes := []rune(text)
|
|
if len(runes) <= limit {
|
|
return text
|
|
}
|
|
return string(runes[:limit-1]) + "…"
|
|
}
|
|
|
|
func shortBotID(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if len(value) <= 8 {
|
|
return value
|
|
}
|
|
return value[:8]
|
|
}
|
|
|
|
func firstNonEmptyString(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|