修复: 缓存原子写与 fork 关系持久化,优化摘要重试
- 原子写: 新增 atomicWriteFile,缓存/收藏/forks 改为临时文件+rename,避免崩溃损坏 - fork 关系: executeFork 记录待绑定项,扫描后按 cwd+时间窗口匹配新会话并持久化 - 摘要重试: 去掉 needDetail 反复刷新逻辑,仅对未生成摘要的会话触发,避免浪费 claude -p 调用 - 脚本转义: pwsh/bash 的 Prompt 回显段转义双引号与 $,防止注入和语法错误 - 输入限制: 工作空间数字输入限制 4 位,避免无限增长 - 品牌统一: 底部版本号文案 绝尘 → U-TABS
This commit is contained in:
+10
-1
@@ -5,7 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Model 主模型
|
// Model 主模型
|
||||||
@@ -30,6 +30,7 @@ type Model struct {
|
|||||||
allSessionsDirty bool // 缓存是否需要重建
|
allSessionsDirty bool // 缓存是否需要重建
|
||||||
cacheDirty bool // session-cache.json 是否需要写盘
|
cacheDirty bool // session-cache.json 是否需要写盘
|
||||||
summaryCount int // 本次会话已生成的摘要数
|
summaryCount int // 本次会话已生成的摘要数
|
||||||
|
pendingForks []pendingFork // executeFork 留下的待绑定项,扫描后解析
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel() *Model {
|
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
|
total := 0
|
||||||
for _, pd := range msg.Projects {
|
for _, pd := range msg.Projects {
|
||||||
total += len(pd.Sessions)
|
total += len(pd.Sessions)
|
||||||
@@ -114,6 +121,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.QuitMsg:
|
case tea.QuitMsg:
|
||||||
|
// 最终落盘:扫描未完成时 Cache 可能为 nil,此时无数据可写,
|
||||||
|
// 已生成的摘要会在下次启动扫描时由 cache 维护
|
||||||
m.flushCache()
|
m.flushCache()
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-9
@@ -13,7 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Session 单个会话的元数据
|
// Session 单个会话的元数据
|
||||||
@@ -497,7 +497,7 @@ func saveCacheWithTimes(home string, m map[string]*cacheEntry, projTimes map[str
|
|||||||
log.Printf("[u-tabs] cache mkdir fail: %v", err)
|
log.Printf("[u-tabs] cache mkdir fail: %v", err)
|
||||||
return
|
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)
|
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)
|
log.Printf("[u-tabs] favorites mkdir fail: %v", err)
|
||||||
return
|
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)
|
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")
|
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 {
|
func loadForks() map[string]string {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -728,9 +736,70 @@ func recordFork(targetID, sourceID string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dir := filepath.Join(home, ".u-tabs")
|
if err := atomicWriteFile(forksPath(home), data, 0o644); err != nil {
|
||||||
os.MkdirAll(dir, 0o755)
|
log.Printf("[u-tabs] forks write fail: %v", err)
|
||||||
os.WriteFile(forksPath(home), data, 0o644)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 摘要与收藏操作 ---
|
// --- AI 摘要与收藏操作 ---
|
||||||
@@ -766,9 +835,9 @@ func (m *Model) nextSummaryCmd() tea.Cmd {
|
|||||||
}
|
}
|
||||||
for _, pd := range m.history.Projects {
|
for _, pd := range m.history.Projects {
|
||||||
for _, s := range pd.Sessions {
|
for _, s := range pd.Sessions {
|
||||||
needSummary := s.AISummary == "" && s.FirstMsg != ""
|
// 仅对从未生成过摘要的会话生成,避免对已有摘要的会话反复重试
|
||||||
needDetail := s.AISummary != "" && len(s.Completed) == 0 && len(s.Pending) == 0
|
// (fallback 路径已用 FirstMsg/ID 充充 AISummary,标记为已生成)
|
||||||
if (needSummary || needDetail) && s.FilePath != "" {
|
if s.AISummary == "" && s.FirstMsg != "" && s.FilePath != "" {
|
||||||
m.summaryCount++
|
m.summaryCount++
|
||||||
return generateSummaryCmd(s.FilePath, s.ID)
|
return generateSummaryCmd(s.FilePath, s.ID)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -1,8 +1,6 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/bubbletea/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- 按键分发 ---
|
// --- 按键分发 ---
|
||||||
|
|
||||||
@@ -92,7 +90,7 @@ func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) {
|
|||||||
return m.launchSelected()
|
return m.launchSelected()
|
||||||
}
|
}
|
||||||
default:
|
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
|
m.inputBuf += s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-5
@@ -7,8 +7,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionAction 描述一个 Claude Code 会话启动动作
|
// 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))
|
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"=== %s ===\" -ForegroundColor Cyan", a.Title))
|
||||||
|
|
||||||
if a.Prompt != "" && a.Name == "" {
|
if a.Prompt != "" && a.Name == "" {
|
||||||
// workspace prompt 显示
|
// workspace prompt 显示(转义双引号,避免破坏 PowerShell 脚本)
|
||||||
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", a.Prompt))
|
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)))
|
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))
|
b.WriteString(fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ==="`, a.Title, a.Title))
|
||||||
|
|
||||||
if a.Prompt != "" && a.Name == "" {
|
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")
|
b.WriteString(" && claude")
|
||||||
|
|
||||||
if a.SessionID != "" {
|
if a.SessionID != "" {
|
||||||
@@ -202,6 +208,14 @@ func (m *Model) executeFork() (*Model, tea.Cmd) {
|
|||||||
m.forkBuf = ""
|
m.forkBuf = ""
|
||||||
action.Execute(m)
|
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
|
title := s.CustomTitle
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = s.ID[:8]
|
title = s.ID[:8]
|
||||||
|
|||||||
@@ -4,12 +4,43 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf16"
|
"unicode/utf16"
|
||||||
"unicode/utf8"
|
"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 (
|
var (
|
||||||
isWindows = runtime.GOOS == "windows"
|
isWindows = runtime.GOOS == "windows"
|
||||||
|
|||||||
+5
-4
@@ -4,9 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"u-tabs/internal/style"
|
"u-tabs/internal/style"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- View 入口 ---
|
// --- View 入口 ---
|
||||||
@@ -66,7 +67,7 @@ func (m *Model) render() string {
|
|||||||
|
|
||||||
helpParts := m.renderHelp()
|
helpParts := m.renderHelp()
|
||||||
helpStr := " " + strings.Join(helpParts, " ")
|
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)
|
padW := m.width - lipgloss.Width(helpStr) - lipgloss.Width(verStr)
|
||||||
if padW > 0 {
|
if padW > 0 {
|
||||||
b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr)
|
b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr)
|
||||||
@@ -129,7 +130,7 @@ func (m *Model) renderHelp() []string {
|
|||||||
|
|
||||||
func (m *Model) fmtHelp(key, desc string) string {
|
func (m *Model) fmtHelp(key, desc string) string {
|
||||||
return style.AccentFg.Render(key) +
|
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) {
|
func renderNavEntry(lines *[]string, w int, label string, activeFg string, isCur, focused bool) {
|
||||||
|
|||||||
Reference in New Issue
Block a user