修复: 缓存原子写与 fork 关系持久化,优化摘要重试

- 原子写: 新增 atomicWriteFile,缓存/收藏/forks 改为临时文件+rename,避免崩溃损坏
- fork 关系: executeFork 记录待绑定项,扫描后按 cwd+时间窗口匹配新会话并持久化
- 摘要重试: 去掉 needDetail 反复刷新逻辑,仅对未生成摘要的会话触发,避免浪费 claude -p 调用
- 脚本转义: pwsh/bash 的 Prompt 回显段转义双引号与 $,防止注入和语法错误
- 输入限制: 工作空间数字输入限制 4 位,避免无限增长
- 品牌统一: 底部版本号文案 绝尘 → U-TABS
This commit is contained in:
lxy
2026-07-01 01:29:01 +08:00
parent baec416df7
commit 45e32193af
6 changed files with 156 additions and 34 deletions
+81 -12
View File
@@ -13,7 +13,7 @@ import (
"strings"
"time"
"charm.land/bubbletea/v2"
tea "charm.land/bubbletea/v2"
)
// Session 单个会话的元数据
@@ -414,9 +414,9 @@ func findStringEnd(data []byte) int {
const cacheVersion = 2
type cacheFile struct {
Version int `json:"version"`
Sessions map[string]*cacheEntry `json:"sessions"`
ProjectTimes map[string]time.Time `json:"projectTimes"` // 项目目录 modTime
Version int `json:"version"`
Sessions map[string]*cacheEntry `json:"sessions"`
ProjectTimes map[string]time.Time `json:"projectTimes"` // 项目目录 modTime
}
type cacheEntry struct {
@@ -497,7 +497,7 @@ func saveCacheWithTimes(home string, m map[string]*cacheEntry, projTimes map[str
log.Printf("[u-tabs] cache mkdir fail: %v", err)
return
}
if err := os.WriteFile(cachePath(home), data, 0o644); err != nil {
if err := atomicWriteFile(cachePath(home), data, 0o644); err != nil {
log.Printf("[u-tabs] cache write fail: %v", err)
}
}
@@ -666,7 +666,7 @@ func saveFavorites(favs map[string]bool) {
log.Printf("[u-tabs] favorites mkdir fail: %v", err)
return
}
if err := os.WriteFile(favoritesPath(home), data, 0o644); err != nil {
if err := atomicWriteFile(favoritesPath(home), data, 0o644); err != nil {
log.Printf("[u-tabs] favorites write fail: %v", err)
}
}
@@ -701,6 +701,14 @@ func forksPath(home string) string {
return filepath.Join(home, ".u-tabs", "forks.json")
}
// pendingFork 记录一次待绑定的 fork:启动 fork 后新会话 ID 未知,
// 等下次扫描时按 cwd + 时间窗口匹配出新建会话再建立关系。
type pendingFork struct {
SourceID string
Cwd string
At time.Time
}
func loadForks() map[string]string {
home, err := os.UserHomeDir()
if err != nil {
@@ -728,9 +736,70 @@ func recordFork(targetID, sourceID string) {
if err != nil {
return
}
dir := filepath.Join(home, ".u-tabs")
os.MkdirAll(dir, 0o755)
os.WriteFile(forksPath(home), data, 0o644)
if err := atomicWriteFile(forksPath(home), data, 0o644); err != nil {
log.Printf("[u-tabs] forks write fail: %v", err)
}
}
// resolvePendingForks 处理 executeFork 留下的待绑定项:
// 在最新扫描结果中,找出 cwd 匹配且 StartTime 晚于记录时刻的新会话,
// 将其作为 fork 目标记录关系到 forks.json。
// 匹配窗口 10 分钟,避免时钟偏差;同一 cwd 只匹配最早的一条新会话。
func resolvePendingForks(pending []pendingFork, projects []*ProjectDir) {
if len(pending) == 0 {
return
}
forks := loadForks()
changed := false
for _, p := range pending {
// 已绑过则跳过
matched := ""
for _, pd := range projects {
if normalizeCwd(pd.Dir) != normalizeCwd(p.Cwd) {
continue
}
for _, s := range pd.Sessions {
if s.StartTime.IsZero() {
continue
}
// 新会话起始时间晚于 fork 时刻(容许 2 秒时钟偏差)
if !s.StartTime.Before(p.At.Add(-2 * time.Second)) {
if s.ID == p.SourceID {
continue
}
if matched == "" || s.StartTime.Before(findSessionStart(projects, matched)) {
matched = s.ID
}
}
}
}
if matched != "" {
forks[matched] = p.SourceID
changed = true
}
}
if changed {
home, err := os.UserHomeDir()
if err == nil {
data, _ := json.Marshal(forks)
atomicWriteFile(forksPath(home), data, 0o644)
}
}
}
func normalizeCwd(s string) string {
return strings.ReplaceAll(s, "\\", "/")
}
func findSessionStart(projects []*ProjectDir, id string) time.Time {
for _, pd := range projects {
for _, s := range pd.Sessions {
if s.ID == id {
return s.StartTime
}
}
}
return time.Time{}
}
// --- AI 摘要与收藏操作 ---
@@ -766,9 +835,9 @@ func (m *Model) nextSummaryCmd() tea.Cmd {
}
for _, pd := range m.history.Projects {
for _, s := range pd.Sessions {
needSummary := s.AISummary == "" && s.FirstMsg != ""
needDetail := s.AISummary != "" && len(s.Completed) == 0 && len(s.Pending) == 0
if (needSummary || needDetail) && s.FilePath != "" {
// 仅对从未生成过摘要的会话生成,避免对已有摘要的会话反复重试
// (fallback 路径已用 FirstMsg/ID 充充 AISummary,标记为已生成)
if s.AISummary == "" && s.FirstMsg != "" && s.FilePath != "" {
m.summaryCount++
return generateSummaryCmd(s.FilePath, s.ID)
}