From 45e32193af15f6b7551bea50523e0035cde83e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 1 Jul 2026 01:29:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E5=8E=9F=E5=AD=90=E5=86=99=E4=B8=8E=20fork=20=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=E6=8C=81=E4=B9=85=E5=8C=96=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=91=98=E8=A6=81=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原子写: 新增 atomicWriteFile,缓存/收藏/forks 改为临时文件+rename,避免崩溃损坏 - fork 关系: executeFork 记录待绑定项,扫描后按 cwd+时间窗口匹配新会话并持久化 - 摘要重试: 去掉 needDetail 反复刷新逻辑,仅对未生成摘要的会话触发,避免浪费 claude -p 调用 - 脚本转义: pwsh/bash 的 Prompt 回显段转义双引号与 $,防止注入和语法错误 - 输入限制: 工作空间数字输入限制 4 位,避免无限增长 - 品牌统一: 底部版本号文案 绝尘 → U-TABS --- internal/app.go | 25 ++++++++---- internal/history.go | 93 +++++++++++++++++++++++++++++++++++++++------ internal/key.go | 6 +-- internal/launch.go | 24 +++++++++--- internal/util.go | 33 +++++++++++++++- internal/view.go | 9 +++-- 6 files changed, 156 insertions(+), 34 deletions(-) diff --git a/internal/app.go b/internal/app.go index dce11ed..5f66251 100644 --- a/internal/app.go +++ b/internal/app.go @@ -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 } diff --git a/internal/history.go b/internal/history.go index 9ce7244..53dd715 100644 --- a/internal/history.go +++ b/internal/history.go @@ -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) } diff --git a/internal/key.go b/internal/key.go index 10bb8e0..6cb1d04 100644 --- a/internal/key.go +++ b/internal/key.go @@ -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 } } diff --git a/internal/launch.go b/internal/launch.go index 56cb944..c468af9 100644 --- a/internal/launch.go +++ b/internal/launch.go @@ -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] diff --git a/internal/util.go b/internal/util.go index b418a1a..ad68723 100644 --- a/internal/util.go +++ b/internal/util.go @@ -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 ) diff --git a/internal/view.go b/internal/view.go index e444758..584a4ee 100644 --- a/internal/view.go +++ b/internal/view.go @@ -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) {