返回 DeepSeek-Reasonix
project_index.go
根目录 / internal / bot / project_index.go
1 package bot
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "io/fs"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "sort"
16 "strings"
17 "time"
18
19 "reasonix/internal/agent"
20 "reasonix/internal/proc"
21 "reasonix/internal/secrets"
22 )
23
24 const (
25 botProjectListLimit = 20
26 botSessionListLimit = 20
27 botSearchListLimit = 20
28 )
29
30 type botProjectEntry struct {
31 ID string
32 Name string
33 Root string
34 Sources []string
35 }
36
37 type botSessionEntry struct {
38 ID string
39 ProjectID string
40 ProjectName string
41 WorkspaceRoot string
42 SessionID string
43 SessionPath string
44 RemoteID string
45 ConnectionID string
46 ChatType string
47 UserID string
48 ThreadID string
49 Scope string
50 Preview string
51 TopicTitle string
52 LastActivityAt time.Time
53 Source string
54 }
55
56 type botProjectSearchResult struct {
57 ProjectID string
58 ProjectName string
59 Path string
60 Line int
61 Text string
62 }
63
64 func (gw *BotGateway) buildProjectIndex() []botProjectEntry {
65 collector := newBotProjectCollector()
66 collector.add(gw.cfg.WorkspaceRoot, "default")
67
68 // cfg.Channels / cfg.ConnectionChannels are rewritten under gw.mu at runtime,
69 // so the whole scan shares the controllers critical section below.
70 gw.mu.Lock()
71 platforms := make([]string, 0, len(gw.cfg.Channels))
72 for platform := range gw.cfg.Channels {
73 platforms = append(platforms, string(platform))
74 }
75 sort.Strings(platforms)
76 for _, platform := range platforms {
77 channel := gw.cfg.Channels[Platform(platform)]
78 source := "channel:" + platform
79 collector.add(channel.WorkspaceRoot, source)
80 addMappingProjects(collector, channel, source)
81 }
82
83 connections := make([]string, 0, len(gw.cfg.ConnectionChannels))
84 for id := range gw.cfg.ConnectionChannels {
85 connections = append(connections, id)
86 }
87 sort.Strings(connections)
88 for _, id := range connections {
89 channel := gw.cfg.ConnectionChannels[id]
90 source := "connection:" + id
91 collector.add(channel.WorkspaceRoot, source)
92 addMappingProjects(collector, channel, source)
93 }
94
95 for i, route := range gw.cfg.Routes {
96 collector.add(route.Channel.WorkspaceRoot, fmt.Sprintf("route:%d", i+1))
97 }
98
99 for key, state := range gw.controllers {
100 root := ""
101 if state != nil {
102 root = state.workspaceRoot
103 if root == "" && state.ctrl != nil {
104 root = state.ctrl.WorkspaceRoot()
105 }
106 }
107 collector.add(root, "active:"+shortBotID(key))
108 }
109 for key, override := range gw.sessionOverrides {
110 collector.add(override.channel.WorkspaceRoot, "override:"+shortBotID(key))
111 }
112 gw.mu.Unlock()
113
114 return collector.entries()
115 }
116
117 func addMappingProjects(collector *botProjectCollector, channel ChannelConfig, source string) {
118 for _, mapping := range channel.SessionMappings {
119 root := workspaceRootForSessionMapping(mapping, channel.WorkspaceRoot)
120 collector.add(root, source+":mapping:"+strings.TrimSpace(mapping.RemoteID))
121 }
122 }
123
124 type botProjectCollector struct {
125 byRoot map[string]*botProjectEntry
126 }
127
128 func newBotProjectCollector() *botProjectCollector {
129 return &botProjectCollector{byRoot: make(map[string]*botProjectEntry)}
130 }
131
132 func (c *botProjectCollector) add(root, source string) {
133 root = canonicalBotPath(root)
134 if root == "" {
135 return
136 }
137 entry := c.byRoot[root]
138 if entry == nil {
139 entry = &botProjectEntry{Name: botProjectName(root), Root: root}
140 c.byRoot[root] = entry
141 }
142 source = strings.TrimSpace(source)
143 if source == "" {
144 return
145 }
146 for _, existing := range entry.Sources {
147 if existing == source {
148 return
149 }
150 }
151 entry.Sources = append(entry.Sources, source)
152 }
153
154 func (c *botProjectCollector) entries() []botProjectEntry {
155 out := make([]botProjectEntry, 0, len(c.byRoot))
156 for _, entry := range c.byRoot {
157 copied := *entry
158 sort.Strings(copied.Sources)
159 out = append(out, copied)
160 }
161 sort.Slice(out, func(i, j int) bool {
162 li := strings.ToLower(out[i].Name)
163 lj := strings.ToLower(out[j].Name)
164 if li != lj {
165 return li < lj
166 }
167 return out[i].Root < out[j].Root
168 })
169 for i := range out {
170 out[i].ID = fmt.Sprintf("p%d", i+1)
171 }
172 return out
173 }
174
175 func (gw *BotGateway) buildSessionIndex(projects []botProjectEntry) []botSessionEntry {
176 projectByRoot := make(map[string]botProjectEntry, len(projects))
177 for _, project := range projects {
178 projectByRoot[canonicalBotPath(project.Root)] = project
179 }
180 collector := newBotSessionCollector(projectByRoot)
181
182 // cfg.Channels / cfg.ConnectionChannels are rewritten under gw.mu at runtime;
183 // collect the mapping-derived entries under a short lock, keeping the
184 // filesystem scan below outside it.
185 gw.mu.Lock()
186 platforms := make([]string, 0, len(gw.cfg.Channels))
187 for platform := range gw.cfg.Channels {
188 platforms = append(platforms, string(platform))
189 }
190 sort.Strings(platforms)
191 for _, platform := range platforms {
192 channel := gw.cfg.Channels[Platform(platform)]
193 addMappingSessions(collector, channel, "", "channel:"+platform)
194 }
195
196 connections := make([]string, 0, len(gw.cfg.ConnectionChannels))
197 for id := range gw.cfg.ConnectionChannels {
198 connections = append(connections, id)
199 }
200 sort.Strings(connections)
201 for _, id := range connections {
202 channel := gw.cfg.ConnectionChannels[id]
203 addMappingSessions(collector, channel, id, "connection:"+id)
204 }
205 gw.mu.Unlock()
206
207 for _, project := range projects {
208 dir := botSessionDir(project.Root)
209 if dir == "" {
210 continue
211 }
212 if info, err := os.Stat(dir); err != nil || !info.IsDir() {
213 continue
214 }
215 infos, err := agent.ListSessions(dir)
216 if err != nil {
217 gw.logger.Warn("bot project session index failed", "project", project.Name, "err", err)
218 continue
219 }
220 for _, info := range infos {
221 collector.add(botSessionEntry{
222 WorkspaceRoot: project.Root,
223 SessionPath: canonicalBotPath(info.Path),
224 SessionID: botSessionTarget(info.Path),
225 Scope: firstNonEmptyString(info.Scope, "project"),
226 Preview: info.Preview,
227 TopicTitle: info.TopicTitle,
228 LastActivityAt: info.LastActivityAt,
229 Source: "project-sessions",
230 })
231 }
232 }
233
234 return collector.entries()
235 }
236
237 func addMappingSessions(collector *botSessionCollector, channel ChannelConfig, connectionID, source string) {
238 for _, mapping := range channel.SessionMappings {
239 sessionID := strings.TrimSpace(mapping.SessionID)
240 sessionPath := botSessionPathFromTarget(sessionID)
241 root := workspaceRootForSessionMapping(mapping, channel.WorkspaceRoot)
242 collector.add(botSessionEntry{
243 WorkspaceRoot: root,
244 SessionID: sessionID,
245 SessionPath: sessionPath,
246 RemoteID: strings.TrimSpace(mapping.RemoteID),
247 ConnectionID: strings.TrimSpace(connectionID),
248 ChatType: strings.TrimSpace(mapping.ChatType),
249 UserID: strings.TrimSpace(mapping.UserID),
250 ThreadID: strings.TrimSpace(mapping.ThreadID),
251 Scope: strings.TrimSpace(mapping.Scope),
252 LastActivityAt: parseBotMappingUpdatedAt(mapping.UpdatedAt),
253 Source: source,
254 })
255 }
256 }
257
258 type botSessionCollector struct {
259 projectByRoot map[string]botProjectEntry
260 byKey map[string]*botSessionEntry
261 }
262
263 func newBotSessionCollector(projectByRoot map[string]botProjectEntry) *botSessionCollector {
264 return &botSessionCollector{
265 projectByRoot: projectByRoot,
266 byKey: make(map[string]*botSessionEntry),
267 }
268 }
269
270 func (c *botSessionCollector) add(entry botSessionEntry) {
271 entry.WorkspaceRoot = canonicalBotPath(entry.WorkspaceRoot)
272 entry.SessionPath = canonicalBotPath(entry.SessionPath)
273 if entry.SessionID == "" && entry.SessionPath != "" {
274 entry.SessionID = botSessionTarget(entry.SessionPath)
275 }
276 if entry.SessionPath == "" && entry.SessionID == "" && entry.RemoteID == "" {
277 return
278 }
279 if project, ok := c.projectByRoot[entry.WorkspaceRoot]; ok {
280 entry.ProjectID = project.ID
281 entry.ProjectName = project.Name
282 }
283 key := entry.SessionPath
284 if key == "" {
285 key = strings.Join([]string{"target", entry.ConnectionID, entry.RemoteID, entry.ChatType, entry.UserID, entry.ThreadID, entry.SessionID}, "\x00")
286 }
287 existing := c.byKey[key]
288 if existing == nil {
289 c.byKey[key] = &entry
290 return
291 }
292 mergeBotSessionEntry(existing, entry)
293 }
294
295 func mergeBotSessionEntry(dst *botSessionEntry, src botSessionEntry) {
296 if dst.ProjectID == "" {
297 dst.ProjectID = src.ProjectID
298 }
299 if dst.ProjectName == "" {
300 dst.ProjectName = src.ProjectName
301 }
302 if dst.WorkspaceRoot == "" {
303 dst.WorkspaceRoot = src.WorkspaceRoot
304 }
305 if dst.SessionID == "" {
306 dst.SessionID = src.SessionID
307 }
308 if dst.SessionPath == "" {
309 dst.SessionPath = src.SessionPath
310 }
311 if dst.RemoteID == "" {
312 dst.RemoteID = src.RemoteID
313 }
314 if dst.ConnectionID == "" {
315 dst.ConnectionID = src.ConnectionID
316 }
317 if dst.ChatType == "" {
318 dst.ChatType = src.ChatType
319 }
320 if dst.UserID == "" {
321 dst.UserID = src.UserID
322 }
323 if dst.ThreadID == "" {
324 dst.ThreadID = src.ThreadID
325 }
326 if dst.Scope == "" {
327 dst.Scope = src.Scope
328 }
329 if dst.Preview == "" {
330 dst.Preview = src.Preview
331 }
332 if dst.TopicTitle == "" {
333 dst.TopicTitle = src.TopicTitle
334 }
335 if src.LastActivityAt.After(dst.LastActivityAt) {
336 dst.LastActivityAt = src.LastActivityAt
337 }
338 if dst.Source == "" {
339 dst.Source = src.Source
340 } else if src.Source != "" && !strings.Contains(dst.Source, src.Source) {
341 dst.Source += "," + src.Source
342 }
343 }
344
345 func (c *botSessionCollector) entries() []botSessionEntry {
346 out := make([]botSessionEntry, 0, len(c.byKey))
347 for _, entry := range c.byKey {
348 out = append(out, *entry)
349 }
350 sort.Slice(out, func(i, j int) bool {
351 if !out[i].LastActivityAt.Equal(out[j].LastActivityAt) {
352 return out[i].LastActivityAt.After(out[j].LastActivityAt)
353 }
354 if out[i].ProjectName != out[j].ProjectName {
355 return out[i].ProjectName < out[j].ProjectName
356 }
357 return out[i].SessionPath < out[j].SessionPath
358 })
359 for i := range out {
360 out[i].ID = fmt.Sprintf("s%d", i+1)
361 }
362 return out
363 }
364
365 func botSessionPathFromTarget(target string) string {
366 target = strings.TrimSpace(target)
367 if target == "" {
368 return ""
369 }
370 if strings.HasPrefix(target, "path:") {
371 return canonicalBotPath(strings.TrimPrefix(target, "path:"))
372 }
373 if filepath.IsAbs(target) && strings.HasSuffix(target, ".jsonl") {
374 return canonicalBotPath(target)
375 }
376 return ""
377 }
378
379 func parseBotMappingUpdatedAt(value string) time.Time {
380 value = strings.TrimSpace(value)
381 if value == "" {
382 return time.Time{}
383 }
384 if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
385 return t
386 }
387 if t, err := time.Parse(time.RFC3339, value); err == nil {
388 return t
389 }
390 return time.Time{}
391 }
392
393 func resolveBotProject(projects []botProjectEntry, selector string) (botProjectEntry, []botProjectEntry) {
394 selector = strings.TrimSpace(selector)
395 if selector == "" {
396 return botProjectEntry{}, nil
397 }
398 selectorLower := strings.ToLower(selector)
399 canonicalSelector := canonicalBotPath(selector)
400 var matches []botProjectEntry
401 for _, project := range projects {
402 if strings.EqualFold(project.ID, selector) || canonicalBotPath(project.Root) == canonicalSelector || strings.EqualFold(project.Name, selector) {
403 return project, nil
404 }
405 if strings.Contains(strings.ToLower(project.Name), selectorLower) || strings.Contains(strings.ToLower(project.Root), selectorLower) {
406 matches = append(matches, project)
407 }
408 }
409 if len(matches) == 1 {
410 return matches[0], nil
411 }
412 return botProjectEntry{}, matches
413 }
414
415 func resolveBotSession(sessions []botSessionEntry, selector string) (botSessionEntry, []botSessionEntry) {
416 selector = strings.TrimSpace(selector)
417 if selector == "" {
418 return botSessionEntry{}, nil
419 }
420 selectorLower := strings.ToLower(selector)
421 canonicalSelector := canonicalBotPath(selector)
422 var matches []botSessionEntry
423 for _, session := range sessions {
424 if strings.EqualFold(session.ID, selector) ||
425 (session.SessionPath != "" && canonicalBotPath(session.SessionPath) == canonicalSelector) ||
426 (session.SessionPath != "" && strings.EqualFold(filepath.Base(session.SessionPath), selector)) ||
427 (session.SessionID != "" && strings.EqualFold(session.SessionID, selector)) {
428 return session, nil
429 }
430 if botSessionMatchesQuery(session, selectorLower) {
431 matches = append(matches, session)
432 }
433 }
434 if len(matches) == 1 {
435 return matches[0], nil
436 }
437 return botSessionEntry{}, matches
438 }
439
440 func filterBotProjects(projects []botProjectEntry, query string) []botProjectEntry {
441 query = strings.ToLower(strings.TrimSpace(query))
442 if query == "" {
443 return projects
444 }
445 var out []botProjectEntry
446 for _, project := range projects {
447 if strings.Contains(strings.ToLower(project.Name+" "+project.Root+" "+strings.Join(project.Sources, " ")), query) {
448 out = append(out, project)
449 }
450 }
451 return out
452 }
453
454 func filterBotSessions(sessions []botSessionEntry, query string) []botSessionEntry {
455 query = strings.ToLower(strings.TrimSpace(query))
456 if query == "" {
457 return sessions
458 }
459 var out []botSessionEntry
460 for _, session := range sessions {
461 if botSessionMatchesQuery(session, query) {
462 out = append(out, session)
463 }
464 }
465 return out
466 }
467
468 func botSessionMatchesQuery(session botSessionEntry, query string) bool {
469 haystack := strings.ToLower(strings.Join([]string{
470 session.ID,
471 session.ProjectID,
472 session.ProjectName,
473 session.WorkspaceRoot,
474 session.SessionID,
475 session.SessionPath,
476 session.RemoteID,
477 session.ConnectionID,
478 session.ChatType,
479 session.UserID,
480 session.ThreadID,
481 session.Scope,
482 session.Preview,
483 session.TopicTitle,
484 session.Source,
485 }, " "))
486 return strings.Contains(haystack, query)
487 }
488
489 func formatBotProjects(projects []botProjectEntry, query string, limit int) string {
490 matches := filterBotProjects(projects, query)
491 if len(matches) == 0 {
492 if strings.TrimSpace(query) == "" {
493 return "还没有可用项目索引。请先在 bot 连接、route 或当前会话里配置 workspace_root。"
494 }
495 return "没有匹配的项目。"
496 }
497 if limit <= 0 || limit > len(matches) {
498 limit = len(matches)
499 }
500 var b strings.Builder
501 fmt.Fprintf(&b, "项目索引(%d/%d):", limit, len(matches))
502 for i := 0; i < limit; i++ {
503 project := matches[i]
504 fmt.Fprintf(&b, "\n%s %s — %s", project.ID, project.Name, displayBotPath(project.Root))
505 if len(project.Sources) > 0 {
506 fmt.Fprintf(&b, "\n 来源: %s", strings.Join(project.Sources, ", "))
507 }
508 }
509 if len(matches) > limit {
510 fmt.Fprintf(&b, "\n还有 %d 个结果,请加关键词缩小范围。", len(matches)-limit)
511 }
512 return b.String()
513 }
514
515 func formatBotSessions(sessions []botSessionEntry, query string, limit int) string {
516 matches := filterBotSessions(sessions, query)
517 if len(matches) == 0 {
518 if strings.TrimSpace(query) == "" {
519 return "还没有可用会话索引。已有项目会话或 bot session_mappings 后会出现在这里。"
520 }
521 return "没有匹配的会话。"
522 }
523 if limit <= 0 || limit > len(matches) {
524 limit = len(matches)
525 }
526 var b strings.Builder
527 fmt.Fprintf(&b, "会话索引(%d/%d):", limit, len(matches))
528 for i := 0; i < limit; i++ {
529 session := matches[i]
530 project := firstNonEmptyString(session.ProjectName, "global")
531 fmt.Fprintf(&b, "\n%s %s", session.ID, project)
532 if session.TopicTitle != "" {
533 fmt.Fprintf(&b, " · %s", singleLineBotText(session.TopicTitle, 40))
534 }
535 if session.Preview != "" {
536 fmt.Fprintf(&b, "\n 预览: %s", singleLineBotText(session.Preview, 90))
537 }
538 if session.SessionPath != "" {
539 fmt.Fprintf(&b, "\n 文件: %s", displayBotPath(session.SessionPath))
540 } else if session.SessionID != "" {
541 fmt.Fprintf(&b, "\n 目标: %s", session.SessionID)
542 }
543 if session.RemoteID != "" || session.ConnectionID != "" {
544 fmt.Fprintf(&b, "\n 远端: %s %s", session.ConnectionID, session.RemoteID)
545 }
546 }
547 if len(matches) > limit {
548 fmt.Fprintf(&b, "\n还有 %d 个结果,请加关键词缩小范围。", len(matches)-limit)
549 }
550 return b.String()
551 }
552
553 func formatBotProjectSearchResults(results []botProjectSearchResult, limit int) string {
554 if len(results) == 0 {
555 return "没有跨项目命中。"
556 }
557 if limit <= 0 || limit > len(results) {
558 limit = len(results)
559 }
560 var b strings.Builder
561 fmt.Fprintf(&b, "跨项目检索结果(%d/%d):", limit, len(results))
562 for i := 0; i < limit; i++ {
563 result := results[i]
564 project := firstNonEmptyString(result.ProjectName, result.ProjectID)
565 fmt.Fprintf(&b, "\n- %s %s:%d: %s", project, displayBotPath(result.Path), result.Line, singleLineBotText(result.Text, 120))
566 }
567 if len(results) > limit {
568 fmt.Fprintf(&b, "\n还有 %d 条命中,请加关键词缩小范围。", len(results)-limit)
569 }
570 return b.String()
571 }
572
573 func searchBotProjects(ctx context.Context, projects []botProjectEntry, query string, limit int) ([]botProjectSearchResult, error) {
574 query = strings.TrimSpace(query)
575 if len([]rune(query)) < 2 {
576 return nil, errors.New("检索词至少需要 2 个字符")
577 }
578 var roots []string
579 seen := map[string]bool{}
580 for _, project := range projects {
581 root := canonicalBotPath(project.Root)
582 if root == "" || seen[root] {
583 continue
584 }
585 info, err := os.Stat(root)
586 if err != nil || !info.IsDir() {
587 continue
588 }
589 seen[root] = true
590 roots = append(roots, root)
591 }
592 if len(roots) == 0 {
593 return nil, errors.New("没有可检索的项目目录")
594 }
595 if limit <= 0 {
596 limit = botSearchListLimit
597 }
598 if rg, err := exec.LookPath("rg"); err == nil {
599 return searchBotProjectsWithRG(ctx, rg, projects, roots, query, limit)
600 }
601 return searchBotProjectsFallback(ctx, projects, roots, query, limit)
602 }
603
604 func searchBotProjectsWithRG(ctx context.Context, rg string, projects []botProjectEntry, roots []string, query string, limit int) ([]botProjectSearchResult, error) {
605 args := []string{
606 "--json",
607 "--color", "never",
608 "--fixed-strings",
609 "--max-count", "3",
610 "--max-filesize", "1M",
611 "--glob", "!.git",
612 "--glob", "!node_modules",
613 "--glob", "!dist",
614 "--glob", "!build",
615 "--glob", "!vendor",
616 "--",
617 query,
618 }
619 args = append(args, roots...)
620 cmd := exec.CommandContext(ctx, rg, args...)
621 cmd.Env = secrets.ProcessEnv()
622 // The desktop app hosts bot bridges in the GUI process; without this an
623 // rg search flashes a console window on Windows.
624 proc.HideWindow(cmd)
625 out, err := cmd.Output()
626 if err != nil {
627 var exitErr *exec.ExitError
628 if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
629 return nil, nil
630 }
631 return nil, fmt.Errorf("rg failed: %w", err)
632 }
633 dec := json.NewDecoder(bytes.NewReader(out))
634 var results []botProjectSearchResult
635 for {
636 var item struct {
637 Type string `json:"type"`
638 Data struct {
639 Path struct {
640 Text string `json:"text"`
641 } `json:"path"`
642 Lines struct {
643 Text string `json:"text"`
644 } `json:"lines"`
645 LineNumber int `json:"line_number"`
646 } `json:"data"`
647 }
648 if err := dec.Decode(&item); err != nil {
649 if errors.Is(err, io.EOF) {
650 break
651 }
652 break
653 }
654 if item.Type != "match" {
655 continue
656 }
657 path := canonicalBotPath(item.Data.Path.Text)
658 project := botProjectForPath(projects, path)
659 results = append(results, botProjectSearchResult{
660 ProjectID: project.ID,
661 ProjectName: project.Name,
662 Path: path,
663 Line: item.Data.LineNumber,
664 Text: strings.TrimSpace(item.Data.Lines.Text),
665 })
666 if len(results) >= limit {
667 break
668 }
669 }
670 return results, nil
671 }
672
673 var errStopBotSearch = errors.New("stop bot project search")
674
675 func searchBotProjectsFallback(ctx context.Context, projects []botProjectEntry, roots []string, query string, limit int) ([]botProjectSearchResult, error) {
676 queryLower := strings.ToLower(query)
677 var results []botProjectSearchResult
678 for _, root := range roots {
679 if err := ctx.Err(); err != nil {
680 return results, err
681 }
682 walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
683 if err != nil {
684 return nil
685 }
686 if err := ctx.Err(); err != nil {
687 return err
688 }
689 if d.IsDir() {
690 if shouldSkipBotSearchDir(d.Name()) && path != root {
691 return filepath.SkipDir
692 }
693 return nil
694 }
695 info, err := d.Info()
696 if err != nil || info.Size() > 1024*1024 {
697 return nil
698 }
699 file, err := os.Open(path)
700 if err != nil {
701 return nil
702 }
703 scanner := bufio.NewScanner(file)
704 scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
705 line := 0
706 for scanner.Scan() {
707 if err := ctx.Err(); err != nil {
708 _ = file.Close()
709 return err
710 }
711 line++
712 text := scanner.Text()
713 if strings.Contains(strings.ToLower(text), queryLower) {
714 project := botProjectForPath(projects, path)
715 results = append(results, botProjectSearchResult{
716 ProjectID: project.ID,
717 ProjectName: project.Name,
718 Path: canonicalBotPath(path),
719 Line: line,
720 Text: strings.TrimSpace(text),
721 })
722 if len(results) >= limit {
723 _ = file.Close()
724 return errStopBotSearch
725 }
726 }
727 }
728 _ = file.Close()
729 return nil
730 })
731 if errors.Is(walkErr, errStopBotSearch) {
732 break
733 }
734 if walkErr != nil {
735 return results, walkErr
736 }
737 if len(results) >= limit {
738 break
739 }
740 }
741 return results, nil
742 }
743
744 func shouldSkipBotSearchDir(name string) bool {
745 switch name {
746 case ".git", "node_modules", "dist", "build", "vendor", ".next", ".cache":
747 return true
748 default:
749 return false
750 }
751 }
752
753 func botProjectForPath(projects []botProjectEntry, path string) botProjectEntry {
754 path = canonicalBotPath(path)
755 var best botProjectEntry
756 for _, project := range projects {
757 root := canonicalBotPath(project.Root)
758 if root == "" {
759 continue
760 }
761 if path == root || strings.HasPrefix(path, root+string(os.PathSeparator)) {
762 if len(root) > len(best.Root) {
763 best = project
764 }
765 }
766 }
767 return best
768 }
769
770 func canonicalBotPath(path string) string {
771 path = strings.TrimSpace(path)
772 if path == "" {
773 return ""
774 }
775 if abs, err := filepath.Abs(path); err == nil {
776 path = abs
777 }
778 return filepath.Clean(path)
779 }
780
781 func botProjectName(root string) string {
782 root = strings.TrimRight(canonicalBotPath(root), string(os.PathSeparator))
783 if root == "" {
784 return ""
785 }
786 name := filepath.Base(root)
787 if name == "." || name == string(os.PathSeparator) {
788 return root
789 }
790 return name
791 }
792
793 func displayBotPath(path string) string {
794 path = canonicalBotPath(path)
795 home, err := os.UserHomeDir()
796 if err == nil {
797 home = canonicalBotPath(home)
798 if home != "" && (path == home || strings.HasPrefix(path, home+string(os.PathSeparator))) {
799 return "~" + strings.TrimPrefix(path, home)
800 }
801 }
802 return path
803 }
804
805 func singleLineBotText(text string, limit int) string {
806 text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
807 if limit <= 0 {
808 return text
809 }
810 runes := []rune(text)
811 if len(runes) <= limit {
812 return text
813 }
814 return string(runes[:limit-1]) + "…"
815 }
816
817 func shortBotID(value string) string {
818 value = strings.TrimSpace(value)
819 if len(value) <= 8 {
820 return value
821 }
822 return value[:8]
823 }
824
825 func firstNonEmptyString(values ...string) string {
826 for _, value := range values {
827 if strings.TrimSpace(value) != "" {
828 return strings.TrimSpace(value)
829 }
830 }
831 return ""
832 }
833
833 lines GO