新增: 会话分叉功能,优化增量扫描缓存

- 新增 c 键触发会话分叉,支持带方向提示的分叉输入
- 使用 claude --fork-session 原生分叉,不污染原会话
- Init() 启动时自动触发历史扫描,无需手动切 tab
- 缓存提升到 HistoryState 内存持有,避免重复 I/O
- 新增项目目录 modTime 增量扫描,未变目录跳过遍历
- 扫描后裁剪缓存,删除磁盘已不存在的条目
- updateSummaryInCache 直接操作内存缓存
This commit is contained in:
2026-05-28 16:18:32 +08:00
parent a6d5c024e7
commit 0bd9848df9
2 changed files with 254 additions and 32 deletions

View File

@@ -30,6 +30,7 @@ type Session struct {
Completed []string // AI 提取的已完成项
Pending []string // AI 提取的待办项
FilePath string // JSONL 文件完整路径
ForkFrom string // 分叉来源 session ID空表示非分叉
}
// ProjectDir 按目录分组的会话集合
@@ -89,6 +90,7 @@ type HistoryState struct {
Loaded bool
Scanning bool
Favorites map[string]bool
Cache map[string]*cacheEntry // 内存缓存,避免重复 I/O
}
// IsHistoryTab 判断当前是否在 HISTORY Tab
@@ -100,36 +102,53 @@ func (m *Model) IsHistoryTab() bool {
type ScanCompleteMsg struct {
Projects []*ProjectDir
UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要
Cache map[string]*cacheEntry // 更新后的缓存
UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要
}
func ScanSessionsCmd() tea.Cmd {
return func() tea.Msg {
projects, updated := scanAllProjects()
return ScanCompleteMsg{Projects: projects, UpdatedIDs: updated}
projects, cache, updated := scanAllProjects()
return ScanCompleteMsg{Projects: projects, Cache: cache, UpdatedIDs: updated}
}
}
func scanAllProjects() ([]*ProjectDir, map[string]bool) {
func scanAllProjects() ([]*ProjectDir, map[string]*cacheEntry, map[string]bool) {
home, err := os.UserHomeDir()
if err != nil {
return nil, nil
return nil, nil, nil
}
projectsDir := filepath.Join(home, ".claude", "projects")
entries, err := os.ReadDir(projectsDir)
if err != nil {
return nil, nil
return nil, nil, nil
}
cache := loadCache(home)
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
@@ -139,13 +158,14 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) {
continue
}
sessionID := strings.TrimSuffix(f.Name(), ".jsonl")
seenIDs[sessionID] = true
filePath := filepath.Join(projectPath, f.Name())
info, err := f.Info()
finfo, err := f.Info()
if err != nil {
continue
}
if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(info.ModTime()) {
if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(finfo.ModTime()) {
addSessionToDir(dirMap, cached.ToSession(sessionID))
continue
}
@@ -160,11 +180,31 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) {
session.Pending = nil
updatedIDs[sessionID] = true
addSessionToDir(dirMap, session)
cache[sessionID] = cacheEntryFrom(session, info.ModTime())
cache[sessionID] = cacheEntryFrom(session, finfo.ModTime())
}
}
saveCache(home, cache)
// 裁剪缓存:删除磁盘已不存在的条目
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 {
@@ -176,7 +216,20 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) {
sort.Slice(result, func(i, j int) bool {
return result[i].Dir < result[j].Dir
})
return result, updatedIDs
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) {
@@ -360,8 +413,9 @@ func findStringEnd(data []byte) int {
const cacheVersion = 2
type cacheFile struct {
Version int `json:"version"`
Sessions map[string]*cacheEntry `json:"sessions"`
Version int `json:"version"`
Sessions map[string]*cacheEntry `json:"sessions"`
ProjectTimes map[string]time.Time `json:"projectTimes"` // 项目目录 modTime
}
type cacheEntry struct {
@@ -377,6 +431,7 @@ type cacheEntry struct {
Completed []string `json:"completed"`
Pending []string `json:"pending"`
FilePath string `json:"filePath"`
ForkFrom string `json:"forkFrom"`
}
func (e *cacheEntry) ToSession(id string) *Session {
@@ -386,7 +441,7 @@ func (e *cacheEntry) ToSession(id string) *Session {
MsgCount: e.MsgCount, FirstMsg: e.FirstMsg,
AwaySummary: e.AwaySummary, AISummary: e.AISummary,
Completed: e.Completed, Pending: e.Pending,
FilePath: e.FilePath,
FilePath: e.FilePath, ForkFrom: e.ForkFrom,
}
}
@@ -397,7 +452,7 @@ func cacheEntryFrom(s *Session, modTime time.Time) *cacheEntry {
MsgCount: s.MsgCount, FirstMsg: s.FirstMsg,
AwaySummary: s.AwaySummary, AISummary: s.AISummary,
Completed: s.Completed, Pending: s.Pending,
FilePath: s.FilePath,
FilePath: s.FilePath, ForkFrom: s.ForkFrom,
}
}
@@ -406,19 +461,32 @@ func cachePath(home string) string {
}
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)
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)
return make(map[string]*cacheEntry), make(map[string]time.Time)
}
return cf.Sessions
projTimes := cf.ProjectTimes
if projTimes == nil {
projTimes = make(map[string]time.Time)
}
return cf.Sessions, projTimes
}
func saveCache(home string, m map[string]*cacheEntry) {
cf := cacheFile{Version: cacheVersion, Sessions: m}
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
@@ -536,12 +604,10 @@ func extractMessagesForSummary(path string) string {
return strings.Join(msgs, "\n")
}
func saveSummaryToCache(sessionID, summary string, completed, pending []string) {
home, err := os.UserHomeDir()
if err != nil {
func updateSummaryInCache(cache map[string]*cacheEntry, sessionID, summary string, completed, pending []string) {
if cache == nil {
return
}
cache := loadCache(home)
entry, ok := cache[sessionID]
if !ok {
return
@@ -549,6 +615,11 @@ func saveSummaryToCache(sessionID, summary string, completed, pending []string)
entry.AISummary = summary
entry.Completed = completed
entry.Pending = pending
// 异步持久化
home, err := os.UserHomeDir()
if err != nil {
return
}
saveCache(home, cache)
}
@@ -624,3 +695,41 @@ func collectFavorites(projects []*ProjectDir, favs map[string]bool) []*Session {
}
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)
}