Files
u-tabs/internal/history.go

627 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package internal
import (
"bufio"
"bytes"
"context"
"encoding/json"
"log"
"os"
"os/exec"
"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 生成的摘要 (缓存)
Completed []string // AI 提取的已完成项
Pending []string // AI 提取的待办项
FilePath string // JSONL 文件完整路径
}
// ProjectDir 按目录分组的会话集合
type ProjectDir struct {
Dir string // 完整路径
DirShort string // 显示名 (路径最后一段)
Sessions []*Session // 按时间倒序
}
// DisplayTitle 返回会话显示标题
func (s *Session) DisplayTitle() string {
return cleanText(s.displayTitleRaw())
}
func (s *Session) displayTitleRaw() 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 {
return cleanText(s.displaySummaryRaw())
}
func (s *Session) displaySummaryRaw() string {
if s.AwaySummary != "" {
return s.AwaySummary
}
return s.FirstMsg
}
// cleanText 清理显示文本:多空格保留一个,换行转空格
func cleanText(s string) string {
s = strings.ReplaceAll(s, "\r\n", " ")
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\t", " ")
for strings.Contains(s, " ") {
s = strings.ReplaceAll(s, " ", " ")
}
return strings.TrimSpace(s)
}
// HistoryState HISTORY Tab 视图状态
type HistoryState struct {
Projects []*ProjectDir
DirCursor int // 左栏光标
SessCursor int // 中栏光标
FocusPanel int // 0=左栏 1=中栏
Loaded bool
Scanning bool
Favorites map[string]bool
}
// IsHistoryTab 判断当前是否在 HISTORY Tab
func (m *Model) IsHistoryTab() bool {
return m.activeGroup == len(Groups)
}
// --- 扫描 ---
type ScanCompleteMsg struct {
Projects []*ProjectDir
UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要
}
func ScanSessionsCmd() tea.Cmd {
return func() tea.Msg {
projects, updated := scanAllProjects()
return ScanCompleteMsg{Projects: projects, UpdatedIDs: updated}
}
}
func scanAllProjects() ([]*ProjectDir, map[string]bool) {
home, err := os.UserHomeDir()
if err != nil {
return nil, nil
}
projectsDir := filepath.Join(home, ".claude", "projects")
entries, err := os.ReadDir(projectsDir)
if err != nil {
return nil, nil
}
cache := loadCache(home)
dirMap := make(map[string]*ProjectDir)
updatedIDs := make(map[string]bool)
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
}
// 清除旧摘要,标记需要重新生成
session.AISummary = ""
session.Completed = nil
session.Pending = nil
updatedIDs[sessionID] = true
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, updatedIDs
}
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, FilePath: path}
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineCount := 0
firstMsgFound := false
for scanner.Scan() {
lineCount++
line := scanner.Bytes()
// 前 20 行内出现 queue-operation → claude -p 会话,跳过
if lineCount <= 20 && matchType(line, "queue-operation") {
return nil
}
if !hasType(line) {
continue
}
// 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
}
}
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+7:], &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 = 2
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"`
Completed []string `json:"completed"`
Pending []string `json:"pending"`
FilePath string `json:"filePath"`
}
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,
Completed: e.Completed, Pending: e.Pending,
FilePath: e.FilePath,
}
}
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,
Completed: s.Completed, Pending: s.Pending,
FilePath: s.FilePath,
}
}
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]) + "..."
}
// --- AI 摘要生成 ---
type SummaryResultMsg struct {
SessionID string
Summary string
Completed []string
Pending []string
}
func generateSummaryCmd(filePath, sessionID string) tea.Cmd {
return func() tea.Msg {
sum, done, todo := generateAISummary(filePath)
return SummaryResultMsg{SessionID: sessionID, Summary: sum, Completed: done, Pending: todo}
}
}
type aiSummaryResult struct {
Summary string `json:"summary"`
Completed []string `json:"completed"`
Pending []string `json:"pending"`
}
func generateAISummary(filePath string) (string, []string, []string) {
messages := extractMessagesForSummary(filePath)
if messages == "" {
return "", nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
prompt := `分析以下Claude Code对话用JSON输出
{"summary":"20字内中文概括","completed":["已完成项1","已完成项2"],"pending":["待办项1"]}
要求summary 20字内概括completed 已完成工作每项≤30字pending 未完成/待办每项≤30字。只输出JSON。` + "\n\n" + messages
cmd := exec.CommandContext(ctx, "claude", "-p", prompt)
output, err := cmd.Output()
if err != nil {
return "", nil, nil
}
raw := strings.TrimSpace(string(output))
raw = strings.Trim(raw, "`")
raw = strings.TrimPrefix(raw, "json")
raw = strings.TrimSpace(raw)
var result aiSummaryResult
if json.Unmarshal([]byte(raw), &result) != nil {
// Fallback: treat whole output as summary
s := strings.Trim(raw, "\"'`*")
s = strings.TrimPrefix(s, "概括:")
s = strings.TrimPrefix(s, "主题:")
if len(s) > 60 {
s = truncStr(s, 60)
}
return s, nil, nil
}
if len(result.Summary) > 60 {
result.Summary = truncStr(result.Summary, 60)
}
return result.Summary, result.Completed, result.Pending
}
func extractMessagesForSummary(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
var msgs []string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
userCount := 0
for scanner.Scan() {
if userCount >= 5 {
break
}
line := scanner.Bytes()
if !hasType(line) {
continue
}
if matchType(line, "user") {
if msg := extractUserMsg(line); msg != "" {
msgs = append(msgs, "用户: "+msg)
userCount++
}
}
}
return strings.Join(msgs, "\n")
}
func saveSummaryToCache(sessionID, summary string, completed, pending []string) {
home, err := os.UserHomeDir()
if err != nil {
return
}
cache := loadCache(home)
entry, ok := cache[sessionID]
if !ok {
return
}
entry.AISummary = summary
entry.Completed = completed
entry.Pending = pending
saveCache(home, cache)
}
// --- 收藏 ---
func favoritesPath(home string) string {
return filepath.Join(home, ".u-tabs", "favorites.json")
}
func loadFavorites() map[string]bool {
home, err := os.UserHomeDir()
if err != nil {
return make(map[string]bool)
}
data, err := os.ReadFile(favoritesPath(home))
if err != nil {
return make(map[string]bool)
}
var ids []string
if json.Unmarshal(data, &ids) != nil {
return make(map[string]bool)
}
m := make(map[string]bool, len(ids))
for _, id := range ids {
m[id] = true
}
return m
}
func saveFavorites(favs map[string]bool) {
home, err := os.UserHomeDir()
if err != nil {
return
}
ids := make([]string, 0, len(favs))
for id := range favs {
ids = append(ids, id)
}
data, err := json.Marshal(ids)
if err != nil {
return
}
dir := filepath.Join(home, ".u-tabs")
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Printf("[u-tabs] favorites mkdir fail: %v", err)
return
}
if err := os.WriteFile(favoritesPath(home), data, 0o644); err != nil {
log.Printf("[u-tabs] favorites write fail: %v", err)
}
}
func countFavorites(projects []*ProjectDir, favs map[string]bool) int {
n := 0
for _, pd := range projects {
for _, s := range pd.Sessions {
if favs[s.ID] {
n++
}
}
}
return n
}
func collectFavorites(projects []*ProjectDir, favs map[string]bool) []*Session {
var out []*Session
for _, pd := range projects {
for _, s := range pd.Sessions {
if favs[s.ID] {
out = append(out, s)
}
}
}
return out
}