新增: HISTORY 历史会话功能
三栏布局(目录/会话/详情), 扫描 ~/.claude/projects JSONL, 支持 resume 会话, 缓存增量更新, 代码审查问题修复
This commit is contained in:
406
internal/history.go
Normal file
406
internal/history.go
Normal file
@@ -0,0 +1,406 @@
|
||||
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, "<command") &&
|
||||
!strings.HasPrefix(content, "<system-reminder") &&
|
||||
!strings.HasPrefix(content, "[Request interrupted") {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
// array 格式: [{"type":"text","text":"xxx"}]
|
||||
if idx := bytes.Index(line, []byte(`"type":"text"`)); idx >= 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]) + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user