From 2c438e3bb9d33dea1aedbe5d6fd2d3ac911586c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 3 Jun 2026 12:11:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E6=B6=88=E8=80=97=E4=BC=98=E5=8C=96=EF=BC=8C=E5=8F=91=E5=B8=83?= =?UTF-8?q?=20v0.2.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app.go | 37 ++++++++++++++++++++++++++++++++++++- internal/history.go | 17 ++++++++--------- internal/key.go | 16 ++++++++++++---- internal/style/style.go | 14 ++++++++++++++ internal/update.go | 2 +- internal/util.go | 14 ++++++++++++++ internal/view.go | 30 +++++++++++++----------------- internal/view_hist.go | 19 +++++++++---------- internal/view_ws.go | 14 ++++++-------- 9 files changed, 113 insertions(+), 50 deletions(-) diff --git a/internal/app.go b/internal/app.go index 40dee90..30ab527 100644 --- a/internal/app.go +++ b/internal/app.go @@ -2,6 +2,8 @@ package internal import ( "fmt" + "os" + "time" "charm.land/bubbletea/v2" ) @@ -22,6 +24,11 @@ type Model struct { wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) forkMode bool // 分叉输入模式 forkBuf string // 分叉方向提示输入 + + // 性能缓存 + allSessionsCache []*Session // "全部" tab 会话切片缓存 + allSessionsDirty bool // 缓存是否需要重建 + cacheDirty bool // session-cache.json 是否需要写盘 } func NewModel() *Model { @@ -52,6 +59,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.history.Cache = msg.Cache m.history.Loaded = true m.history.Scanning = false + m.allSessionsDirty = true + m.cacheDirty = true if m.history.Favorites == nil { m.history.Favorites = loadFavorites() } @@ -77,7 +86,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case SummaryResultMsg: m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending) - return m, m.nextSummaryCmd() + m.cacheDirty = true + cmds := []tea.Cmd{m.nextSummaryCmd(), cacheFlushTick()} + return m, tea.Batch(cmds...) case UpdateAvailableMsg: m.update.Checking = false m.update.Available = true @@ -94,6 +105,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.update.Updating = false m.update.Error = msg.Err m.update.Checking = false + case cacheFlushMsg: + m.flushCache() + return m, nil + case tea.QuitMsg: + m.flushCache() + return m, tea.Quit } return m, nil } @@ -101,3 +118,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) GetPendingCmd() string { return m.pendingCmd } + +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{} + }) +} diff --git a/internal/history.go b/internal/history.go index ce129fc..047e1c4 100644 --- a/internal/history.go +++ b/internal/history.go @@ -128,6 +128,7 @@ func scanAllProjects() ([]*ProjectDir, map[string]*cacheEntry, map[string]bool) dirMap := make(map[string]*ProjectDir) updatedIDs := make(map[string]bool) seenIDs := make(map[string]bool) + scanBuf := make([]byte, 0, 64*1024) // 共享扫描缓冲区 for _, entry := range entries { if !entry.IsDir() { @@ -170,7 +171,7 @@ func scanAllProjects() ([]*ProjectDir, map[string]*cacheEntry, map[string]bool) continue } - session := scanSessionFile(filePath, sessionID) + session := scanSessionFile(filePath, sessionID, &scanBuf) if session == nil || session.Cwd == "" { continue } @@ -246,7 +247,7 @@ func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) { const scanLineLimit = 500 -func scanSessionFile(path string, sessionID string) *Session { +func scanSessionFile(path string, sessionID string, buf *[]byte) *Session { f, err := os.Open(path) if err != nil { return nil @@ -255,7 +256,7 @@ func scanSessionFile(path string, sessionID string) *Session { s := &Session{ID: sessionID, FilePath: path} scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + scanner.Buffer(*buf, 1024*1024) lineCount := 0 firstMsgFound := false @@ -615,14 +616,12 @@ func updateSummaryInCache(cache map[string]*cacheEntry, sessionID, summary strin entry.AISummary = summary entry.Completed = completed entry.Pending = pending - // 异步持久化 - home, err := os.UserHomeDir() - if err != nil { - return - } - saveCache(home, cache) + // 持久化由 Model.flushCache() 延迟批量写入 } +// cacheFlushMsg 延迟写缓存定时器消息 +type cacheFlushMsg struct{} + // --- 收藏 --- func favoritesPath(home string) string { diff --git a/internal/key.go b/internal/key.go index daefac5..10bb8e0 100644 --- a/internal/key.go +++ b/internal/key.go @@ -222,11 +222,19 @@ func (m *Model) moveHistorySess(dir int) { func (m *Model) currentSessions() []*Session { if m.history.DirCursor == 0 { - var all []*Session - for _, pd := range m.history.Projects { - all = append(all, pd.Sessions...) + if m.allSessionsDirty || m.allSessionsCache == nil { + total := 0 + for _, pd := range m.history.Projects { + total += len(pd.Sessions) + } + all := make([]*Session, 0, total) + for _, pd := range m.history.Projects { + all = append(all, pd.Sessions...) + } + m.allSessionsCache = all + m.allSessionsDirty = false } - return all + return m.allSessionsCache } if m.history.DirCursor == 1 { return collectFavorites(m.history.Projects, m.history.Favorites) diff --git a/internal/style/style.go b/internal/style/style.go index a63e597..f50fb37 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -57,4 +57,18 @@ var ( SessionSummaryStyle = lipgloss.NewStyle().Foreground(Dim).Italic(true).Inline(true) DirCountStyle = lipgloss.NewStyle().Foreground(Accent).Inline(true) ScanningStyle = lipgloss.NewStyle().Foreground(Warning).Bold(true) + + // ── 渲染热路径预构建样式 (避免每帧 NewStyle) ── + AccentFg = lipgloss.NewStyle().Foreground(Accent) + DimFg = lipgloss.NewStyle().Foreground(Dim) + SepFg = lipgloss.NewStyle().Foreground(BgPanel) + SuccessBoldFg = lipgloss.NewStyle().Foreground(Success).Bold(true) + WarningFg = lipgloss.NewStyle().Foreground(Warning) + WarningBoldFg = lipgloss.NewStyle().Foreground(Warning).Bold(true) + RedFg = lipgloss.NewStyle().Foreground(Red) + CyanFg = lipgloss.NewStyle().Foreground(Cyan) + AccentItalicFg = lipgloss.NewStyle().Foreground(Accent).Italic(true) + AccentBoldFg = lipgloss.NewStyle().Foreground(Accent).Bold(true) + TabActiveAccent = lipgloss.NewStyle().Bold(true).Background(BgPanel).Foreground(Accent) + TabActiveCyan = lipgloss.NewStyle().Bold(true).Background(BgPanel).Foreground(Cyan) ) diff --git a/internal/update.go b/internal/update.go index fb57705..fbba9d8 100644 --- a/internal/update.go +++ b/internal/update.go @@ -14,7 +14,7 @@ import ( ) // Version 当前版本,发布时更新 -const Version = "0.2.0" +const Version = "0.2.1" // --- 远程 JSON 结构 --- diff --git a/internal/util.go b/internal/util.go index 6f67856..b418a1a 100644 --- a/internal/util.go +++ b/internal/util.go @@ -21,6 +21,20 @@ func toWinPath(s string) string { return strings.ReplaceAll(s, "/", "\\") } +// --- 分隔线缓存 --- + +var sepCache = make(map[int]string) + +// sepRunes 返回 width 个 "─" 字符,结果按 width 缓存。 +func sepRunes(w int) string { + if s, ok := sepCache[w]; ok { + return s + } + s := strings.Repeat("─", w) + sepCache[w] = s + return s +} + // --- 字符串宽度 --- func stringWidth(s string) int { diff --git a/internal/view.go b/internal/view.go index dc99038..e444758 100644 --- a/internal/view.go +++ b/internal/view.go @@ -28,7 +28,7 @@ func (m *Model) render() string { b.WriteString(m.renderTabBar()) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", m.width))) + b.WriteString(style.SepFg.Render(sepRunes(m.width))) b.WriteString("\n") if m.IsHistoryTab() { @@ -46,27 +46,27 @@ func (m *Model) render() string { b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ⎇ fork:%s [Enter]confirm [Esc]cancel", m.forkBuf))) } if m.launched != "" { - b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched))) + b.WriteString(style.AccentFg.Render(fmt.Sprintf(" ✓ %s", m.launched))) m.launched = "" } if m.update.Done { - b.WriteString(lipgloss.NewStyle().Foreground(style.Success).Bold(true). + b.WriteString(style.SuccessBoldFg. Render(fmt.Sprintf(" updated to v%s, restart to apply", m.update.NewVersion))) } else if m.update.Error != nil { - b.WriteString(lipgloss.NewStyle().Foreground(style.Red). + b.WriteString(style.RedFg. Render(fmt.Sprintf(" update failed: %v", m.update.Error))) } else if m.update.Updating { - b.WriteString(lipgloss.NewStyle().Foreground(style.Warning). + b.WriteString(style.WarningFg. Render(" updating...")) } else if m.update.Available { - b.WriteString(lipgloss.NewStyle().Foreground(style.Warning).Bold(true). + b.WriteString(style.WarningBoldFg. Render(fmt.Sprintf(" v%s available [u]update", m.update.NewVersion))) } b.WriteString("\n") helpParts := m.renderHelp() helpStr := " " + strings.Join(helpParts, " ") - verStr := lipgloss.NewStyle().Foreground(style.Dim).Render("绝尘 v" + Version) + verStr := style.DimFg.Render("绝尘 v" + Version) padW := m.width - lipgloss.Width(helpStr) - lipgloss.Width(verStr) if padW > 0 { b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr) @@ -88,9 +88,7 @@ func (m *Model) renderTabBar() string { wsLabel := " 1 工作空间 " if !m.IsHistoryTab() { - b.WriteString(lipgloss.NewStyle(). - Bold(true).Background(style.BgPanel).Foreground(style.Accent). - Render(wsLabel)) + b.WriteString(style.TabActiveAccent.Render(wsLabel)) } else { b.WriteString(style.TabInactiveStyle.Render(wsLabel)) } @@ -98,9 +96,7 @@ func (m *Model) renderTabBar() string { b.WriteString(sep) histLabel := " 2 历史对话 " if m.IsHistoryTab() { - b.WriteString(lipgloss.NewStyle(). - Bold(true).Background(style.BgPanel).Foreground(style.Cyan). - Render(histLabel)) + b.WriteString(style.TabActiveCyan.Render(histLabel)) } else { b.WriteString(style.TabInactiveStyle.Render(histLabel)) } @@ -132,12 +128,12 @@ func (m *Model) renderHelp() []string { } func (m *Model) fmtHelp(key, desc string) string { - return lipgloss.NewStyle().Foreground(style.Accent).Render(key) + - lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc) + return style.AccentFg.Render(key) + + style.DimFg.Render(":" + desc) } func renderNavEntry(lines *[]string, w int, label string, activeFg string, isCur, focused bool) { - sep := lipgloss.NewStyle().Foreground(style.BgPanel).Render(fitWidth(" ─────────", w)) + sep := style.SepFg.Render(fitWidth(" ─────────", w)) *lines = append(*lines, sep) if isCur { curLabel := " → " + label @@ -150,6 +146,6 @@ func renderNavEntry(lines *[]string, w int, label string, activeFg string, isCur } else { inactiveLabel := " " + label line := padRightByWidth(truncateByWidth(inactiveLabel, w), w) - *lines = append(*lines, lipgloss.NewStyle().Foreground(style.Dim).Render(line)) + *lines = append(*lines, style.DimFg.Render(line)) } } diff --git a/internal/view_hist.go b/internal/view_hist.go index efab8c7..b325420 100644 --- a/internal/view_hist.go +++ b/internal/view_hist.go @@ -189,10 +189,9 @@ func (m *Model) sessColumnLines(w, listH int) []string { func (m *Model) detailColumnLines(w, listH int) []string { s := m.currentSession() if s == nil { - return []string{lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" ← select to view", w))} + return []string{style.DimFg.Render(fitWidth(" ← select to view", w))} } - sepSty := lipgloss.NewStyle().Foreground(style.BgPanel) var lines []string favPrefix := " " @@ -200,7 +199,7 @@ func (m *Model) detailColumnLines(w, listH int) []string { favPrefix = "★ " } lines = append(lines, style.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w))) - lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) + lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w))) fav := "-" if m.history.Favorites[s.ID] { @@ -218,34 +217,34 @@ func (m *Model) detailColumnLines(w, listH int) []string { } if summary := s.DisplaySummary(); summary != "" { - lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) + lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w))) lines = append(lines, style.SessionSummaryStyle.Render(fitWidth(" "+summary, w))) } if len(s.Completed) > 0 { - lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) - lines = append(lines, lipgloss.NewStyle().Foreground(style.Success).Bold(true).Render(fitWidth(" ✓ 已完成", w))) + lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w))) + lines = append(lines, style.SuccessBoldFg.Render(fitWidth(" ✓ 已完成", w))) for _, item := range s.Completed { lines = append(lines, style.NormStyle.Render(fitWidth(" · "+cleanText(item), w))) } } if len(s.Pending) > 0 { - lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) - lines = append(lines, lipgloss.NewStyle().Foreground(style.Warning).Bold(true).Render(fitWidth(" ○ 待办", w))) + lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w))) + lines = append(lines, style.WarningBoldFg.Render(fitWidth(" ○ 待办", w))) for _, item := range s.Pending { lines = append(lines, style.NormStyle.Render(fitWidth(" · "+cleanText(item), w))) } } - lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]resume", w))) + lines = append(lines, style.DimFg.Render(fitWidth(" [Enter]resume", w))) if s.ForkFrom != "" { forkID := s.ForkFrom if len(forkID) > 8 { forkID = forkID[:8] } - lines = append(lines, lipgloss.NewStyle().Foreground(style.Cyan).Render(fitWidth(fmt.Sprintf(" ⎇ fork from %s", forkID), w))) + lines = append(lines, style.CyanFg.Render(fitWidth(fmt.Sprintf(" ⎇ fork from %s", forkID), w))) } if len(lines) > listH { diff --git a/internal/view_ws.go b/internal/view_ws.go index 23ee88f..702efa6 100644 --- a/internal/view_ws.go +++ b/internal/view_ws.go @@ -2,7 +2,6 @@ package internal import ( "fmt" - "strings" "charm.land/lipgloss/v2" "u-tabs/internal/style" @@ -127,7 +126,7 @@ func (m *Model) wsListLines(g Group, svcs []Workspace, w, listH int) []string { text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW) sty := style.SelStyle if m.wsFocus != 1 { - sty = lipgloss.NewStyle().Foreground(style.Accent).Bold(true) + sty = style.AccentBoldFg } lines = append(lines, sty.Render(padRightByWidth(truncateByWidth(prefix+text, w), w))) } else { @@ -147,15 +146,14 @@ func (m *Model) wsListLines(g Group, svcs []Workspace, w, listH int) []string { func (m *Model) wsDetailLines(svcs []Workspace, w, listH int) []string { if m.cursor >= len(svcs) { - return []string{lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" ← select to view", w))} + return []string{style.DimFg.Render(fitWidth(" ← select to view", w))} } ws := svcs[m.cursor] - sepSty := lipgloss.NewStyle().Foreground(style.BgPanel) var lines []string lines = append(lines, style.DetailTitle.Render(fitWidth(" "+ws.Title, w))) - lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) + lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w))) rows := []struct { key string @@ -173,14 +171,14 @@ func (m *Model) wsDetailLines(svcs []Workspace, w, listH int) []string { lines = append(lines, style.NormStyle.Render(fitWidth(line, w))) } - lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) + lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w))) hintCmd := "wt" if !isWindows { hintCmd = "bash" } - lines = append(lines, lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(fitWidth(" $ "+hintCmd+" → CC @"+ws.Title, w))) - lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]start [Space]multi", w))) + lines = append(lines, style.AccentItalicFg.Render(fitWidth(" $ "+hintCmd+" → CC @"+ws.Title, w))) + lines = append(lines, style.DimFg.Render(fitWidth(" [Enter]start [Space]multi", w))) if len(lines) > listH { lines = lines[:listH]