修复: 缓存原子写与 fork 关系持久化,优化摘要重试
- 原子写: 新增 atomicWriteFile,缓存/收藏/forks 改为临时文件+rename,避免崩溃损坏 - fork 关系: executeFork 记录待绑定项,扫描后按 cwd+时间窗口匹配新会话并持久化 - 摘要重试: 去掉 needDetail 反复刷新逻辑,仅对未生成摘要的会话触发,避免浪费 claude -p 调用 - 脚本转义: pwsh/bash 的 Prompt 回显段转义双引号与 $,防止注入和语法错误 - 输入限制: 工作空间数字输入限制 4 位,避免无限增长 - 品牌统一: 底部版本号文案 绝尘 → U-TABS
This commit is contained in:
+17
-8
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user