Files
u-tabs/internal/app.go
T
lxy cc58de4661 修复: 重命名时缓存缺条目补建防标题丢失
- 缓存缺 entry(缓存损坏或新会话未入缓存)时补建,标题才能落盘
- 补缓存补建测试,顺带补齐测试文件缺失的换行
2026-10-03 10:13:40 +08:00

225 lines
5.9 KiB
Go

package internal
import (
"fmt"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
)
// Model 主模型
type Model struct {
activeGroup int
cursor int
selected map[int]bool
inputBuf string
width int
height int
launched string
history HistoryState
pendingCmd string
update UpdateState
wsFocus int // workspace 焦点列: 0=tabs 1=list
wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口)
Skills SkillsState
forkMode bool // 分叉输入模式
forkBuf string // 分叉方向提示输入
editTitleMode bool // 编辑标题模式
editTitleBuf string // 标题编辑缓冲区
editTitleCursor int // 标题光标位置(rune 索引)
// 性能缓存
allSessionsCache []*Session // "全部" tab 会话切片缓存
allSessionsDirty bool // 缓存是否需要重建
cacheDirty bool // session-cache.json 是否需要写盘
summaryCount int // 本次会话已生成的摘要数
pendingForks []pendingFork // executeFork 留下的待绑定项,扫描后解析
}
func NewModel() *Model {
return &Model{
activeGroup: len(Groups), // 默认打开历史对话
selected: make(map[int]bool),
}
}
func (m *Model) Init() tea.Cmd {
m.update.Checking = true
cmds := []tea.Cmd{CheckUpdateCmd()}
if cmd := m.onTabSwitch(); cmd != nil {
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKey(msg.String())
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case ScanCompleteMsg:
// 保留尚未落盘的用户自定义标题:扫描从磁盘读缓存,若改名后 3s 内
// 触发扫描(切 tab/刷新),磁盘旧缓存会覆盖内存导致改名丢失。
savedTitles := map[string]string{}
for id, e := range m.history.Cache {
if e.UserTitle != "" {
savedTitles[id] = e.UserTitle
}
}
m.history.Projects = msg.Projects
m.history.Cache = msg.Cache
for id, title := range savedTitles {
if e, ok := m.history.Cache[id]; ok && e.UserTitle == "" {
e.UserTitle = title
m.cacheDirty = true
}
for _, pd := range m.history.Projects {
for _, s := range pd.Sessions {
if s.ID == id {
s.UserTitle = title
}
}
}
}
m.history.Loaded = true
m.history.Scanning = false
m.allSessionsDirty = true
m.cacheDirty = true
if m.history.Favorites == nil {
m.history.Favorites = loadFavorites()
}
// 填充 ForkFrom
forks := loadForks()
if len(forks) > 0 {
for _, pd := range m.history.Projects {
for _, s := range pd.Sessions {
if src, ok := forks[s.ID]; ok {
s.ForkFrom = src
}
}
}
}
// 处理 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)
}
m.launched = fmt.Sprintf("refreshed: %d projects, %d sessions", len(msg.Projects), total)
// 无论本次是否有文件更新,都尝试补齐缺失的摘要
return m, m.nextSummaryCmd()
case ScanSkillsCompleteMsg:
m.Skills.Skills = msg.Skills
m.Skills.Loaded = true
m.Skills.Scanning = false
m.Skills.SkillCur = 0
m.Skills.FileCur = 0
m.Skills.FocusPanel = 0
m.Skills.Content = ""
m.Skills.ContentPath = ""
return m, nil
case SummaryResultMsg:
m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending)
m.cacheDirty = true
cmds := []tea.Cmd{m.nextSummaryCmd(), cacheFlushTick()}
return m, tea.Batch(cmds...)
case UpdateAvailableMsg:
m.update.Checking = false
m.update.Available = true
m.update.NewVersion = msg.NewVersion
m.update.Changelog = msg.Changelog
m.update.DownloadURL = msg.DownloadURL
m.update.SHA256 = msg.SHA256
m.update.FileSize = msg.FileSize
case UpdateCompleteMsg:
m.update.Updating = false
m.update.Done = true
m.update.Available = false
case UpdateErrorMsg:
m.update.Updating = false
m.update.Error = msg.Err
m.update.Checking = false
case cacheFlushMsg:
m.flushCache()
// 只在仍有脏数据时继续调度,避免空转
if m.cacheDirty {
return m, cacheFlushTick()
}
return m, nil
case tea.QuitMsg:
// 最终落盘:扫描未完成时 Cache 可能为 nil,此时无数据可写,
// 已生成的摘要会在下次启动扫描时由 cache 维护
m.flushCache()
return m, tea.Quit
}
return m, nil
}
func (m *Model) GetPendingCmd() string {
return m.pendingCmd
}
func (m *Model) confirmEditTitle() (*Model, tea.Cmd) {
s := m.currentSession()
title := strings.TrimSpace(m.editTitleBuf)
m.editTitleMode = false
m.editTitleBuf = ""
m.editTitleCursor = 0
if s == nil || title == "" {
return m, nil
}
cmd := m.setUserTitle(s.ID, title)
m.launched = "rename: " + title
return m, cmd
}
func (m *Model) setUserTitle(sessionID, title string) tea.Cmd {
for _, pd := range m.history.Projects {
for _, s := range pd.Sessions {
if s.ID == sessionID {
s.UserTitle = title
break
}
}
}
if m.history.Cache != nil {
entry, ok := m.history.Cache[sessionID]
if !ok {
// 缓存缺 entry(如缓存文件损坏/新会话未入缓存)时补建,
// 否则标题只改内存不落盘,重启后丢失
entry = &cacheEntry{}
m.history.Cache[sessionID] = entry
}
entry.UserTitle = title
m.cacheDirty = true
}
// 调度落盘,避免改名只存在内存、被磁盘旧缓存覆盖
return cacheFlushTick()
}
func (m *Model) flushCache() {
if !m.cacheDirty || m.history.Cache == nil {
return
}
home, err := os.UserHomeDir()
if err != nil {
return
}
saveCache(home, m.history.Cache)
m.cacheDirty = false
}
func cacheFlushTick() tea.Cmd {
return tea.Tick(3*time.Second, func(_ time.Time) tea.Msg {
return cacheFlushMsg{}
})
}