package internal import ( "bufio" "bytes" "encoding/json" "log" "os" "path/filepath" "sort" "strings" "time" "charm.land/bubbletea/v2" ) // Session 单个会话的元数据 type Session struct { ID string // UUID (文件名) CustomTitle string // customTitle 字段 Cwd string // 实际工作目录 StartTime time.Time // 首条 timestamp EndTime time.Time // 末条 timestamp MsgCount int // user + assistant 消息数 FirstMsg string // 首条用户消息 (截断) AwaySummary string // away_summary 系统摘要 AISummary string // AI 生成的摘要 (缓存) } // ProjectDir 按目录分组的会话集合 type ProjectDir struct { Dir string // 完整路径 DirShort string // 显示名 (路径最后一段) Sessions []*Session // 按时间倒序 } // DisplayTitle 返回会话显示标题 func (s *Session) DisplayTitle() string { if s.AISummary != "" { return s.AISummary } if s.FirstMsg != "" { return s.FirstMsg } if s.CustomTitle != "" { return s.CustomTitle } return s.ID[:8] } // DisplaySummary 返回会话摘要 func (s *Session) DisplaySummary() string { if s.AwaySummary != "" { return s.AwaySummary } return s.FirstMsg } // HistoryState HISTORY Tab 视图状态 type HistoryState struct { Projects []*ProjectDir DirCursor int // 左栏光标 SessCursor int // 中栏光标 FocusPanel int // 0=左栏 1=中栏 Loaded bool Scanning bool } // IsHistoryTab 判断当前是否在 HISTORY Tab func (m *Model) IsHistoryTab() bool { return m.activeGroup == len(Groups) } // --- 扫描 --- type ScanCompleteMsg struct { Projects []*ProjectDir } func ScanSessionsCmd() tea.Cmd { return func() tea.Msg { return ScanCompleteMsg{Projects: scanAllProjects()} } } func scanAllProjects() []*ProjectDir { home, err := os.UserHomeDir() if err != nil { return nil } projectsDir := filepath.Join(home, ".claude", "projects") entries, err := os.ReadDir(projectsDir) if err != nil { return nil } cache := loadCache(home) dirMap := make(map[string]*ProjectDir) for _, entry := range entries { if !entry.IsDir() { continue } projectPath := filepath.Join(projectsDir, entry.Name()) files, err := os.ReadDir(projectPath) if err != nil { continue } for _, f := range files { if f.IsDir() || !strings.HasSuffix(f.Name(), ".jsonl") { continue } sessionID := strings.TrimSuffix(f.Name(), ".jsonl") filePath := filepath.Join(projectPath, f.Name()) info, err := f.Info() if err != nil { continue } if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(info.ModTime()) { addSessionToDir(dirMap, cached.ToSession(sessionID)) continue } session := scanSessionFile(filePath, sessionID) if session == nil || session.Cwd == "" { continue } addSessionToDir(dirMap, session) cache[sessionID] = cacheEntryFrom(session, info.ModTime()) } } saveCache(home, cache) result := make([]*ProjectDir, 0, len(dirMap)) for _, pd := range dirMap { sort.Slice(pd.Sessions, func(i, j int) bool { return pd.Sessions[i].StartTime.After(pd.Sessions[j].StartTime) }) result = append(result, pd) } sort.Slice(result, func(i, j int) bool { return result[i].Dir < result[j].Dir }) return result } func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) { cwd := strings.ReplaceAll(s.Cwd, "\\", "/") pd, ok := dirMap[cwd] if !ok { pd = &ProjectDir{Dir: cwd, DirShort: filepath.Base(cwd)} dirMap[cwd] = pd } pd.Sessions = append(pd.Sessions, s) } // --- JSONL 扫描 --- const scanLineLimit = 500 func scanSessionFile(path string, sessionID string) *Session { f, err := os.Open(path) if err != nil { return nil } defer f.Close() s := &Session{ID: sessionID} scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) lineCount := 0 firstMsgFound := false scannedLines := 0 // 有效行(含 type 的) for scanner.Scan() { lineCount++ line := scanner.Bytes() if !hasType(line) { continue } scannedLines++ // cwd — 统一为正斜杠 if s.Cwd == "" { extractField(line, `"cwd":"`, &s.Cwd) s.Cwd = strings.ReplaceAll(s.Cwd, "\\", "/") } // timestamp var ts string if extractField(line, `"timestamp":"`, &ts) { if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { if s.StartTime.IsZero() || t.Before(s.StartTime) { s.StartTime = t } if t.After(s.EndTime) { s.EndTime = t } } } switch { case matchType(line, "custom-title"): extractField(line, `"customTitle":"`, &s.CustomTitle) case matchSubtype(line, "away_summary"): extractField(line, `"content":"`, &s.AwaySummary) case matchType(line, "user"): if bytes.Contains(line, []byte(`"tool_use_id"`)) || bytes.Contains(line, []byte(`"isMeta":true`)) { continue } s.MsgCount++ if !firstMsgFound { if msg := extractUserMsg(line); msg != "" { s.FirstMsg = truncStr(msg, 80) firstMsgFound = true } } case matchType(line, "assistant"): s.MsgCount++ } if lineCount > scanLineLimit { break } } // 按 scannedLines/lineCount 比例估算总消息数 if lineCount > scanLineLimit && scannedLines > 0 { ratio := float64(lineCount) / float64(scanLineLimit) s.MsgCount = int(float64(s.MsgCount) * ratio) } return s } // matchType 检查 JSON 行是否包含指定 type 值 func matchType(line []byte, typeName string) bool { compact := `"type":"` + typeName + `"` spaced := `"type": "` + typeName + `"` return bytes.Contains(line, []byte(compact)) || bytes.Contains(line, []byte(spaced)) } // matchSubtype 检查 JSON 行是否包含指定 subtype 值 func matchSubtype(line []byte, subName string) bool { compact := `"subtype":"` + subName + `"` spaced := `"subtype": "` + subName + `"` return bytes.Contains(line, []byte(compact)) || bytes.Contains(line, []byte(spaced)) } // hasType 快速过滤:行是否包含 type 字段 func hasType(line []byte) bool { return bytes.Contains(line, []byte(`"type":"`)) || bytes.Contains(line, []byte(`"type": "`)) } // extractField 从 JSON 行中提取 "key":"value" 的值 func extractField(line []byte, prefix string, out *string) bool { idx := bytes.Index(line, []byte(prefix)) if idx < 0 { return false } // prefix 长度 = len("key\":"),data 从引号开始 return extractJSONString(line[idx+len(prefix)-1:], out) } func extractUserMsg(line []byte) string { // string 格式: "content":"xxx" if idx := bytes.Index(line, []byte(`"content":"`)); idx >= 0 { var content string extractJSONString(line[idx+10:], &content) if content != "" && !strings.HasPrefix(content, "= 0 { searchStart := max(0, idx-200) region := line[searchStart:] if tidx := bytes.Index(region, []byte(`"text":"`)); tidx >= 0 { var text string if extractJSONString(region[tidx+6:], &text) && text != "" && !strings.HasPrefix(text, "Base directory") { return text } } } return "" } // extractJSONString 从 " 开头的 JSON 数据中提取字符串值 func extractJSONString(data []byte, out *string) bool { if len(data) == 0 || data[0] != '"' { return false } // 找结束引号(处理转义) var s string if json.Unmarshal(data[:findStringEnd(data)], &s) == nil { *out = s return true } return false } // findStringEnd 从 " 开始找到结束引号的位置(含) func findStringEnd(data []byte) int { escaped := false for i := 1; i < len(data); i++ { if escaped { escaped = false continue } if data[i] == '\\' { escaped = true continue } if data[i] == '"' { return i + 1 } } return len(data) } // --- 缓存 --- const cacheVersion = 1 type cacheFile struct { Version int `json:"version"` Sessions map[string]*cacheEntry `json:"sessions"` } type cacheEntry struct { ModTime time.Time `json:"modTime"` CustomTitle string `json:"customTitle"` Cwd string `json:"cwd"` StartTime time.Time `json:"startTime"` EndTime time.Time `json:"endTime"` MsgCount int `json:"msgCount"` FirstMsg string `json:"firstMsg"` AwaySummary string `json:"awaySummary"` AISummary string `json:"aiSummary"` } func (e *cacheEntry) ToSession(id string) *Session { return &Session{ ID: id, CustomTitle: e.CustomTitle, Cwd: e.Cwd, StartTime: e.StartTime, EndTime: e.EndTime, MsgCount: e.MsgCount, FirstMsg: e.FirstMsg, AwaySummary: e.AwaySummary, AISummary: e.AISummary, } } func cacheEntryFrom(s *Session, modTime time.Time) *cacheEntry { return &cacheEntry{ ModTime: modTime, CustomTitle: s.CustomTitle, Cwd: s.Cwd, StartTime: s.StartTime, EndTime: s.EndTime, MsgCount: s.MsgCount, FirstMsg: s.FirstMsg, AwaySummary: s.AwaySummary, AISummary: s.AISummary, } } func cachePath(home string) string { return filepath.Join(home, ".u-tabs", "session-cache.json") } func loadCache(home string) map[string]*cacheEntry { data, err := os.ReadFile(cachePath(home)) if err != nil { return make(map[string]*cacheEntry) } var cf cacheFile if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion { return make(map[string]*cacheEntry) } return cf.Sessions } func saveCache(home string, m map[string]*cacheEntry) { cf := cacheFile{Version: cacheVersion, Sessions: m} data, err := json.Marshal(cf) if err != nil { return } dir := filepath.Join(home, ".u-tabs") if err := os.MkdirAll(dir, 0o755); err != nil { log.Printf("[u-tabs] cache mkdir fail: %v", err) return } if err := os.WriteFile(cachePath(home), data, 0o644); err != nil { log.Printf("[u-tabs] cache write fail: %v", err) } } func truncStr(s string, maxRunes int) string { runes := []rune(s) if len(runes) <= maxRunes { return s } return string(runes[:maxRunes]) + "..." }