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