Files
u-tabs/internal/history.go
绝尘 0bd9848df9 新增: 会话分叉功能,优化增量扫描缓存
- 新增 c 键触发会话分叉,支持带方向提示的分叉输入
- 使用 claude --fork-session 原生分叉,不污染原会话
- Init() 启动时自动触发历史扫描,无需手动切 tab
- 缓存提升到 HistoryState 内存持有,避免重复 I/O
- 新增项目目录 modTime 增量扫描,未变目录跳过遍历
- 扫描后裁剪缓存,删除磁盘已不存在的条目
- updateSummaryInCache 直接操作内存缓存
2026-05-28 16:18:32 +08:00

736 lines
18 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 文件完整路径
ForkFrom string // 分叉来源 session ID空表示非分叉
}
// 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
Cache map[string]*cacheEntry // 内存缓存,避免重复 I/O
}
// IsHistoryTab 判断当前是否在 HISTORY Tab
func (m *Model) IsHistoryTab() bool {
return m.activeGroup == len(Groups)
}
// --- 扫描 ---
type ScanCompleteMsg struct {
Projects []*ProjectDir
Cache map[string]*cacheEntry // 更新后的缓存
UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要
}
func ScanSessionsCmd() tea.Cmd {
return func() tea.Msg {
projects, cache, updated := scanAllProjects()
return ScanCompleteMsg{Projects: projects, Cache: cache, UpdatedIDs: updated}
}
}
func scanAllProjects() ([]*ProjectDir, map[string]*cacheEntry, map[string]bool) {
home, err := os.UserHomeDir()
if err != nil {
return nil, nil, nil
}
projectsDir := filepath.Join(home, ".claude", "projects")
entries, err := os.ReadDir(projectsDir)
if err != nil {
return nil, nil, nil
}
cache, projTimes := loadCacheWithTimes(home)
dirMap := make(map[string]*ProjectDir)
updatedIDs := make(map[string]bool)
seenIDs := make(map[string]bool)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
projectPath := filepath.Join(projectsDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
// 项目目录 modTime 未变 → 从缓存直接重建该目录下的会话
if prev, ok := projTimes[projectPath]; ok && prev.Equal(info.ModTime()) {
for _, id := range rebuildFromCache(cache, dirMap, projectPath) {
seenIDs[id] = true
}
continue
}
// 目录有变更,记录新 modTime
projTimes[projectPath] = info.ModTime()
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")
seenIDs[sessionID] = true
filePath := filepath.Join(projectPath, f.Name())
finfo, err := f.Info()
if err != nil {
continue
}
if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(finfo.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, finfo.ModTime())
}
}
// 裁剪缓存:删除磁盘已不存在的条目
for id := range cache {
if !seenIDs[id] {
delete(cache, id)
}
}
// 清理已不存在项目的 projTimes
activeProjects := make(map[string]bool, len(entries))
for _, e := range entries {
if e.IsDir() {
activeProjects[filepath.Join(projectsDir, e.Name())] = true
}
}
for p := range projTimes {
if !activeProjects[p] {
delete(projTimes, p)
}
}
saveCacheWithTimes(home, cache, projTimes)
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, cache, updatedIDs
}
// rebuildFromCache 从缓存重建指定项目目录下的所有会话,返回匹配的 session ID
func rebuildFromCache(cache map[string]*cacheEntry, dirMap map[string]*ProjectDir, projectPath string) []string {
prefix := filepath.Base(projectPath)
var seen []string
for id, e := range cache {
if filepath.Base(filepath.Dir(e.FilePath)) == prefix {
addSessionToDir(dirMap, e.ToSession(id))
seen = append(seen, id)
}
}
return seen
}
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"`
ProjectTimes map[string]time.Time `json:"projectTimes"` // 项目目录 modTime
}
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"`
ForkFrom string `json:"forkFrom"`
}
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, ForkFrom: e.ForkFrom,
}
}
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, ForkFrom: s.ForkFrom,
}
}
func cachePath(home string) string {
return filepath.Join(home, ".u-tabs", "session-cache.json")
}
func loadCache(home string) map[string]*cacheEntry {
c, _ := loadCacheWithTimes(home)
return c
}
func loadCacheWithTimes(home string) (map[string]*cacheEntry, map[string]time.Time) {
data, err := os.ReadFile(cachePath(home))
if err != nil {
return make(map[string]*cacheEntry), make(map[string]time.Time)
}
var cf cacheFile
if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion {
return make(map[string]*cacheEntry), make(map[string]time.Time)
}
projTimes := cf.ProjectTimes
if projTimes == nil {
projTimes = make(map[string]time.Time)
}
return cf.Sessions, projTimes
}
func saveCache(home string, m map[string]*cacheEntry) {
saveCacheWithTimes(home, m, nil)
}
func saveCacheWithTimes(home string, m map[string]*cacheEntry, projTimes map[string]time.Time) {
cf := cacheFile{Version: cacheVersion, Sessions: m, ProjectTimes: projTimes}
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 updateSummaryInCache(cache map[string]*cacheEntry, sessionID, summary string, completed, pending []string) {
if cache == nil {
return
}
entry, ok := cache[sessionID]
if !ok {
return
}
entry.AISummary = summary
entry.Completed = completed
entry.Pending = pending
// 异步持久化
home, err := os.UserHomeDir()
if err != nil {
return
}
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
}
// --- Fork 记录 ---
func forksPath(home string) string {
return filepath.Join(home, ".u-tabs", "forks.json")
}
func loadForks() map[string]string {
home, err := os.UserHomeDir()
if err != nil {
return make(map[string]string)
}
data, err := os.ReadFile(forksPath(home))
if err != nil {
return make(map[string]string)
}
var m map[string]string
if json.Unmarshal(data, &m) != nil {
return make(map[string]string)
}
return m
}
func recordFork(targetID, sourceID string) {
home, err := os.UserHomeDir()
if err != nil {
return
}
forks := loadForks()
forks[targetID] = sourceID
data, err := json.Marshal(forks)
if err != nil {
return
}
dir := filepath.Join(home, ".u-tabs")
os.MkdirAll(dir, 0o755)
os.WriteFile(forksPath(home), data, 0o644)
}