修复: 缓存原子写与 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
+17 -8
View File
@@ -5,7 +5,7 @@ import (
"os"
"time"
"charm.land/bubbletea/v2"
tea "charm.land/bubbletea/v2"
)
// Model 主模型
@@ -20,16 +20,17 @@ type Model struct {
history HistoryState
pendingCmd string
update UpdateState
wsFocus int // workspace 焦点列: 0=tabs 1=list
wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口)
forkMode bool // 分叉输入模式
wsFocus int // workspace 焦点列: 0=tabs 1=list
wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口)
forkMode bool // 分叉输入模式
forkBuf string // 分叉方向提示输入
// 性能缓存
allSessionsCache []*Session // "全部" tab 会话切片缓存
allSessionsDirty bool // 缓存是否需要重建
cacheDirty bool // session-cache.json 是否需要写盘
summaryCount int // 本次会话已生成的摘要数
allSessionsCache []*Session // "全部" tab 会话切片缓存
allSessionsDirty bool // 缓存是否需要重建
cacheDirty bool // session-cache.json 是否需要写盘
summaryCount int // 本次会话已生成的摘要数
pendingForks []pendingFork // executeFork 留下的待绑定项,扫描后解析
}
func NewModel() *Model {
@@ -76,6 +77,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
// 处理 executeFork 留下的待绑定 fork
if len(m.pendingForks) > 0 {
resolvePendingForks(m.pendingForks, m.history.Projects)
m.pendingForks = nil
m.cacheDirty = true
}
total := 0
for _, pd := range msg.Projects {
total += len(pd.Sessions)
@@ -114,6 +121,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.QuitMsg:
// 最终落盘:扫描未完成时 Cache 可能为 nil,此时无数据可写,
// 已生成的摘要会在下次启动扫描时由 cache 维护
m.flushCache()
return m, tea.Quit
}
+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)
}
+2 -4
View File
@@ -1,8 +1,6 @@
package internal
import (
"charm.land/bubbletea/v2"
)
import tea "charm.land/bubbletea/v2"
// --- 按键分发 ---
@@ -92,7 +90,7 @@ func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) {
return m.launchSelected()
}
default:
if len(s) == 1 && s[0] >= '0' && s[0] <= '9' {
if len(s) == 1 && s[0] >= '0' && s[0] <= '9' && len(m.inputBuf) < 4 {
m.inputBuf += s
}
}
+19 -5
View File
@@ -7,8 +7,9 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"charm.land/bubbletea/v2"
tea "charm.land/bubbletea/v2"
)
// SessionAction 描述一个 Claude Code 会话启动动作
@@ -45,8 +46,9 @@ func (a SessionAction) pwshScript() string {
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"=== %s ===\" -ForegroundColor Cyan", a.Title))
if a.Prompt != "" && a.Name == "" {
// workspace prompt 显示
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", a.Prompt))
// workspace prompt 显示(转义双引号,避免破坏 PowerShell 脚本)
pe := strings.ReplaceAll(a.Prompt, "\"", "`\"\"")
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", pe))
}
b.WriteString(fmt.Sprintf("\n\t\tcd \"%s\"", toWinPath(a.Cwd)))
@@ -74,10 +76,14 @@ func (a SessionAction) bashScript() string {
b.WriteString(fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ==="`, a.Title, a.Title))
if a.Prompt != "" && a.Name == "" {
b.WriteString(fmt.Sprintf(` && echo "Prompt: %s"`, a.Prompt))
// bash: 转义双引号与 $ 反斜杠,避免命令注入/语法错误
pe := strings.ReplaceAll(a.Prompt, "\\", "\\\\")
pe = strings.ReplaceAll(pe, "\"", "\\\"")
pe = strings.ReplaceAll(pe, "$", "\\$")
b.WriteString(fmt.Sprintf(" && echo \"Prompt: %s\"", pe))
}
b.WriteString(fmt.Sprintf(` && cd "%s"`, a.Cwd))
b.WriteString(fmt.Sprintf(" && cd \"%s\"", a.Cwd))
b.WriteString(" && claude")
if a.SessionID != "" {
@@ -202,6 +208,14 @@ func (m *Model) executeFork() (*Model, tea.Cmd) {
m.forkBuf = ""
action.Execute(m)
// 记录待绑定 fork:新会话 ID 由 claude CLI 生成,此刻拿不到,
// 留到下次扫描时按 cwd + 时间窗口匹配
m.pendingForks = append(m.pendingForks, pendingFork{
SourceID: s.ID,
Cwd: s.Cwd,
At: time.Now(),
})
title := s.CustomTitle
if title == "" {
title = s.ID[:8]
+32 -1
View File
@@ -4,15 +4,46 @@ import (
"crypto/rand"
"encoding/base64"
"math/big"
"os"
"path/filepath"
"runtime"
"strings"
"unicode/utf16"
"unicode/utf8"
)
// atomicWriteFile 原子写文件:先写临时文件再 rename,避免写入中途崩溃导致目标文件损坏
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
tmp.Close()
if err := os.Chmod(tmpName, perm); err != nil {
os.Remove(tmpName)
return err
}
return os.Rename(tmpName, path)
}
// 平台信息(启动时确定)
var (
isWindows = runtime.GOOS == "windows"
isWindows = runtime.GOOS == "windows"
platformKey = runtime.GOOS + "-" + runtime.GOARCH
)
+5 -4
View File
@@ -4,9 +4,10 @@ import (
"fmt"
"strings"
"charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"u-tabs/internal/style"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// --- View 入口 ---
@@ -66,7 +67,7 @@ func (m *Model) render() string {
helpParts := m.renderHelp()
helpStr := " " + strings.Join(helpParts, " ")
verStr := style.DimFg.Render("绝尘 v" + Version)
verStr := style.DimFg.Render("U-TABS v" + Version)
padW := m.width - lipgloss.Width(helpStr) - lipgloss.Width(verStr)
if padW > 0 {
b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr)
@@ -129,7 +130,7 @@ func (m *Model) renderHelp() []string {
func (m *Model) fmtHelp(key, desc string) string {
return style.AccentFg.Render(key) +
style.DimFg.Render(":" + desc)
style.DimFg.Render(":"+desc)
}
func renderNavEntry(lines *[]string, w int, label string, activeFg string, isCur, focused bool) {