From e125ac608835274083b3cf07c29280b61327d0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 22 May 2026 10:46:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=94=B6=E8=97=8F=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20Tab=20=E5=AF=BC=E8=88=AA=E4=B8=8E=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app.go | 644 +++++++++++++++++++++++++++----------------- internal/history.go | 75 +++++- 2 files changed, 476 insertions(+), 243 deletions(-) diff --git a/internal/app.go b/internal/app.go index ce95505..bbf7ef4 100644 --- a/internal/app.go +++ b/internal/app.go @@ -19,6 +19,8 @@ import ( "u-tabs/internal/style" ) +var isWindows = runtime.GOOS == "windows" + // Model 主模型 type Model struct { activeGroup int @@ -29,8 +31,10 @@ type Model struct { height int launched string history HistoryState - pendingCmd string // Linux: 退出后执行的命令 + pendingCmd string update UpdateState + wsFocus int // workspace 焦点列: 0=tabs 1=list + wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) } func NewModel() *Model { @@ -45,11 +49,6 @@ func (m *Model) Init() tea.Cmd { return CheckUpdateCmd() } -// totalTabs 包含 HISTORY 的总 Tab 数 -func (m *Model) totalTabs() int { - return len(Groups) + 1 -} - func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: @@ -61,6 +60,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.history.Projects = msg.Projects m.history.Loaded = true m.history.Scanning = false + if m.history.Favorites == nil { + m.history.Favorites = loadFavorites() + } total := 0 for _, pd := range msg.Projects { total += len(pd.Sessions) @@ -96,7 +98,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // --- 按键分发 --- func (m *Model) handleKey(s string) (*Model, tea.Cmd) { - // 全局按键 switch s { case "q", "ctrl+c": return m, tea.Quit @@ -107,36 +108,28 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) { } } - // 数字键跳转 Tab - if len(s) == 1 && s[0] >= '1' && s[0] <= '9' { - idx, _ := strconv.Atoi(s) - if idx <= m.totalTabs() { - m.activeGroup = idx - 1 - m.cursor = 0 - m.inputBuf = "" + // Tab 快捷切换: 1=工作空间 2=历史对话 + if s == "1" { + if m.IsHistoryTab() { + m.activeGroup = 0 + m.resetCursor() + m.wsFocus = 1 + } + return m, nil + } + if s == "2" { + if !m.IsHistoryTab() { + m.activeGroup = len(Groups) // HISTORY + m.resetCursor() + m.wsFocus = 1 return m, m.onTabSwitch() } + return m, nil } - // HISTORY Tab: tab/left/right 用于面板切换 if m.IsHistoryTab() { return m.handleHistoryKey(s) } - - // Workspace Tab 按键 - switch s { - case "tab", "right", "l": - m.activeGroup = (m.activeGroup + 1) % m.totalTabs() - m.cursor = 0 - m.inputBuf = "" - return m, m.onTabSwitch() - case "shift+tab", "left", "h": - m.activeGroup = (m.activeGroup - 1 + m.totalTabs()) % m.totalTabs() - m.cursor = 0 - m.inputBuf = "" - return m, m.onTabSwitch() - } - return m.handleWorkspaceKey(s) } @@ -148,21 +141,51 @@ func (m *Model) onTabSwitch() tea.Cmd { return nil } -// --- Workspace Tab 按键 --- +// --- Workspace 按键 --- func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) { switch s { + case "left", "h": + if m.wsFocus > 0 { + m.wsFocus = 0 + } + case "right", "l": + if m.wsFocus < 1 { + m.wsFocus = 1 + } + case "tab", "shift+tab": + m.wsFocus = (m.wsFocus + 1) % 2 case "up", "k": - m.moveCursor(-1) + if m.wsFocus == 0 { + m.moveGroupCursor(-1) + } else { + m.moveCursor(-1) + } case "down", "j": - m.moveCursor(1) - case " ": - m.toggleMultiSelect() + if m.wsFocus == 0 { + m.moveGroupCursor(1) + } else { + m.moveCursor(1) + } case "enter": if m.inputBuf != "" { return m.launchByInput() } - return m.launchSelected() + if m.wsFocus == 0 { + if m.wsTabCur >= len(Groups) { + m.activeGroup = len(Groups) + m.resetCursor() + return m, m.onTabSwitch() + } + m.activeGroup = m.wsTabCur + m.wsFocus = 1 + } else { + return m.launchSelected() + } + case " ": + if m.wsFocus == 1 { + m.toggleMultiSelect() + } default: if len(s) == 1 && s[0] >= '0' && s[0] <= '9' { m.inputBuf += s @@ -171,7 +194,20 @@ func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) { return m, nil } -// --- HISTORY Tab 按键 --- +func (m *Model) resetCursor() { + m.cursor = 0 + m.inputBuf = "" +} + +func (m *Model) moveGroupCursor(dir int) { + total := len(Groups) + 1 // 含历史入口 + m.wsTabCur = (m.wsTabCur + dir + total) % total + if m.wsTabCur < len(Groups) { + m.activeGroup = m.wsTabCur + } + m.resetCursor() +} +// --- HISTORY 按键 --- func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { switch s { @@ -197,7 +233,13 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { } case "enter": if m.history.FocusPanel == 0 { - // 在左栏按 enter 切到中栏 + if m.history.DirCursor == len(m.history.Projects)+2 { + // 回到工作空间 + m.activeGroup = 0 + m.resetCursor() + m.wsTabCur = 0 + m.wsFocus = 1 + } m.history.FocusPanel = 1 } else { return m.resumeSelected() @@ -207,6 +249,8 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { case "r", "f5": m.history.Scanning = true return m, ScanSessionsCmd() + case "f": + m.toggleFavorite() } return m, nil } @@ -216,7 +260,7 @@ func (m *Model) moveHistoryDir(dir int) { return } m.history.DirCursor += dir - maxDir := len(m.history.Projects) + 1 // 含"全部" + maxDir := len(m.history.Projects) + 3 // 全部 + 收藏 + 项目 + 回到工作空间 if m.history.DirCursor < 0 { m.history.DirCursor = maxDir - 1 } @@ -241,66 +285,82 @@ func (m *Model) moveHistorySess(dir int) { } func (m *Model) currentSessions() []*Session { - if len(m.history.Projects) == 0 { - return nil - } if m.history.DirCursor == 0 { - // "全部" — 合并所有目录的会话 var all []*Session for _, pd := range m.history.Projects { all = append(all, pd.Sessions...) } return all } - idx := m.history.DirCursor - 1 - if idx < len(m.history.Projects) { - return m.history.Projects[idx].Sessions + if m.history.DirCursor == 1 { + // 收藏过滤 + return collectFavorites(m.history.Projects, m.history.Favorites) } - return nil + idx := m.history.DirCursor - 2 + if idx >= len(m.history.Projects) { + return nil + } + return m.history.Projects[idx].Sessions } func (m *Model) currentSession() *Session { sessions := m.currentSessions() - if len(sessions) == 0 || m.history.SessCursor >= len(sessions) { + if len(sessions) == 0 { return nil } + if m.history.SessCursor >= len(sessions) { + m.history.SessCursor = len(sessions) - 1 + } return sessions[m.history.SessCursor] } func (m *Model) resumeSelected() (*Model, tea.Cmd) { s := m.currentSession() - if s == nil { - return m, nil - } - if runtime.GOOS == "windows" { + if isWindows { go resumeSession(s) } else { - cwd := s.Cwd title := s.CustomTitle if title == "" { title = s.ID[:8] } - m.pendingCmd = fmt.Sprintf(`printf '\033]0;resume: %s\007' && echo "=== Resuming: %s ===" && cd "%s" && claude -r %s`, title, title, cwd, s.ID) + m.pendingCmd = fmt.Sprintf(`printf '\033]0;resume: %s\007' && echo "=== Resuming: %s ===" && cd "%s" && claude -r %s`, title, title, s.Cwd, s.ID) } m.launched = "resume: " + s.CustomTitle return m, nil } +// currentDirCwd 取当前选中目录路径,"全部" 返回空 +func (m *Model) currentDirCwd() string { + if m.history.DirCursor <= 1 { + return "" + } + idx := m.history.DirCursor - 2 + if idx >= len(m.history.Projects) { + return "" + } + return m.history.Projects[idx].Dir +} + func (m *Model) newSessionFromHistory() (*Model, tea.Cmd) { s := m.currentSession() - if s == nil { + cwd := "" + if s != nil { + cwd = s.Cwd + } else { + cwd = m.currentDirCwd() + } + if cwd == "" { return m, nil } - cwd := s.Cwd - if runtime.GOOS == "windows" { + if isWindows { cwd = strings.ReplaceAll(cwd, "/", "\\") } title := filepath.Base(cwd) + " - new" - if runtime.GOOS == "windows" { + if isWindows { script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s" -Write-Host "=== New Session @ %s ===" -ForegroundColor Cyan -cd "%s" -claude --permission-mode bypassPermissions`, title, cwd, cwd) + Write-Host "=== New Session @ %s ===" -ForegroundColor Cyan + cd "%s" + claude --permission-mode bypassPermissions`, title, cwd, cwd) launchInWtWithTitle(cwd, title, script) } else { m.pendingCmd = fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== New Session @ %s ===" && cd "%s" && claude --permission-mode bypassPermissions`, title, cwd, cwd) @@ -309,7 +369,6 @@ claude --permission-mode bypassPermissions`, title, cwd, cwd) return m, nil } - // --- AI 摘要 --- func (m *Model) applySummary(sessionID, summary string, completed, pending []string) { @@ -341,6 +400,23 @@ func (m *Model) nextSummaryCmd() tea.Cmd { } return nil } + +func (m *Model) toggleFavorite() { + if m.history.Favorites == nil { + m.history.Favorites = loadFavorites() + } + s := m.currentSession() + if s == nil { + return + } + if m.history.Favorites[s.ID] { + delete(m.history.Favorites, s.ID) + } else { + m.history.Favorites[s.ID] = true + } + saveFavorites(m.history.Favorites) +} + // --- Workspace 光标 --- func (m *Model) moveCursor(dir int) { @@ -415,6 +491,36 @@ func (m *Model) View() tea.View { return v } +// renderTabBar 顶部两个主 tab:工作空间 / 历史对话 +func (m *Model) renderTabBar() string { + var b strings.Builder + b.WriteString(style.TitleStyle.Render(" u-tabs v" + Version + " ")) + sep := style.TabSep.Render(" │ ") + + // Tab 1: 工作空间 + wsLabel := " 1 工作空间 " + if !m.IsHistoryTab() { + b.WriteString(lipgloss.NewStyle(). + Bold(true).Background(style.BgPanel).Foreground(style.Accent). + Render(wsLabel)) + } else { + b.WriteString(style.TabInactiveStyle.Render(wsLabel)) + } + + // Tab 2: 历史对话 + b.WriteString(sep) + histLabel := " 2 历史对话 " + if m.IsHistoryTab() { + b.WriteString(lipgloss.NewStyle(). + Bold(true).Background(style.BgPanel).Foreground(style.Cyan). + Render(histLabel)) + } else { + b.WriteString(style.TabInactiveStyle.Render(histLabel)) + } + + return b.String() +} + func (m *Model) render() string { if m.width == 0 { return "loading..." @@ -422,42 +528,8 @@ func (m *Model) render() string { var b strings.Builder - // ── header: title + tabs ── - b.WriteString(style.TitleStyle.Render(" u-tabs v" + Version + " ")) - sep := style.TabSep.Render(" | ") - - // Workspace Tabs - for i, g := range Groups { - if i > 0 { - b.WriteString(sep) - } - label := fmt.Sprintf(" %s %s ", g.Label, g.Desc) - if i == m.activeGroup { - gs, ok := style.GroupStyles[g.Label] - if ok { - b.WriteString(lipgloss.NewStyle(). - Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()). - Render(label)) - } else { - b.WriteString(style.TabActiveStyle.Render(label)) - } - } else { - b.WriteString(style.TabInactiveStyle.Render(label)) - } - } - - // HISTORY Tab - b.WriteString(sep) - histLabel := " HISTORY 历史 " - if m.IsHistoryTab() { - gs := style.GroupStyles["HISTORY"] - b.WriteString(lipgloss.NewStyle(). - Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()). - Render(histLabel)) - } else { - b.WriteString(style.TabInactiveStyle.Render(histLabel)) - } - + // ── header: tab 导航条 + separator ── + b.WriteString(m.renderTabBar()) b.WriteString("\n") b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", m.width))) b.WriteString("\n") @@ -478,20 +550,19 @@ func (m *Model) render() string { b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched))) m.launched = "" } - // update status - if m.update.Done { + if m.update.Done { b.WriteString(lipgloss.NewStyle().Foreground(style.Success).Bold(true). Render(fmt.Sprintf(" updated to v%s, restart to apply", m.update.NewVersion))) - } else if m.update.Error != nil { + } else if m.update.Error != nil { b.WriteString(lipgloss.NewStyle().Foreground(style.Red). Render(fmt.Sprintf(" update failed: %v", m.update.Error))) - } else if m.update.Updating { + } else if m.update.Updating { b.WriteString(lipgloss.NewStyle().Foreground(style.Warning). Render(" updating...")) - } else if m.update.Available { + } else if m.update.Available { b.WriteString(lipgloss.NewStyle().Foreground(style.Warning).Bold(true). Render(fmt.Sprintf(" v%s available [u]update", m.update.NewVersion))) - } + } b.WriteString("\n") helpParts := m.renderHelp() @@ -507,45 +578,118 @@ func (m *Model) renderHelp() []string { if m.IsHistoryTab() { return []string{ m.fmtHelp("j/k", "sel"), - m.fmtHelp("Tab", "→panel"), + m.fmtHelp("←→", "panel"), m.fmtHelp("Enter", "resume"), - m.fmtHelp("n", "new"), - m.fmtHelp("1-"+fmt.Sprintf("%d", m.totalTabs()), "tab"), + m.fmtHelp("f", "star"), + m.fmtHelp("n", "new"), + m.fmtHelp("1", "workspace"), m.fmtHelp("r/F5", "refresh"), m.fmtHelp("q", "quit"), } } return []string{ + m.fmtHelp("←→", "col"), m.fmtHelp("j/k", "sel"), m.fmtHelp("Enter", "run"), m.fmtHelp("Space", "multi"), - m.fmtHelp("Tab", "group"), + m.fmtHelp("2", "history"), m.fmtHelp("q", "quit"), } } -// --- Workspace 渲染 --- +// --- Workspace 三栏渲染 --- func (m *Model) renderWorkspace() string { - listW := max(42, min(65, m.width*55/100)) - detailW := max(30, m.width-listW-3) - g := Groups[m.activeGroup] - gs, _ := style.GroupStyles[g.Label] svcs := WorkspacesByGroup(g.Label) if len(svcs) == 0 { return style.SubtitleStyle.Render(" empty") } - // 预留 header(2) + footer(2),面板边框+标题占 3 行 - availH := max(3, m.height-4-3) - start, end := viewport(m.cursor, len(svcs), availH) + avail := m.width - 8 + tabW := max(14, min(20, avail*18/100)) + listW := max(30, avail*45/100) + detailW := avail - tabW - listW - // 左栏: 列表 - var left strings.Builder - innerW := listW - 4 + tabCw := tabW - 2 + listCw := listW - 2 + detailCw := detailW - 2 + + listH := max(3, m.height-7) + + tabLines := m.tabsColumnLines(tabCw, listH-1) + svcLines := m.wsListLines(g, svcs, listCw, listH-1) + detailLines := m.wsDetailLines(svcs, detailCw, listH-1) + + maxRows := min(listH-1, max(max(len(tabLines), len(svcLines)), len(detailLines))) + + tabBorderFg := style.BgPanel + if m.wsFocus == 0 { + tabBorderFg = style.Accent + } + listBorderFg := style.BgPanel + if m.wsFocus == 1 { + listBorderFg = style.Accent + } + + panelSty := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()) + + tabBox := panelSty.BorderForeground(tabBorderFg).Width(tabW).Render( + style.DetailTitle.Render("tabs") + "\n" + padLinesTo(tabLines, tabCw, maxRows)) + listBox := panelSty.BorderForeground(listBorderFg).Width(listW).Render( + style.DetailTitle.Render(g.Label) + "\n" + padLinesTo(svcLines, listCw, maxRows)) + detailBox := panelSty.BorderForeground(style.BgPanel).Width(detailW).Render( + style.DetailTitle.Render("detail") + "\n" + padLinesTo(detailLines, detailCw, maxRows)) + + return lipgloss.JoinHorizontal(lipgloss.Top, tabBox, " ", listBox, " ", detailBox) +} + +// tabsColumnLines 左侧 tabs 列(工作空间分组 + 历史入口) +func (m *Model) tabsColumnLines(w, listH int) []string { + var lines []string + total := len(Groups) + 1 // 含历史入口 + start, end := viewport(m.wsTabCur, total, listH) + + focused := m.wsFocus == 0 + + for i := start; i < end; i++ { + if i == len(Groups) { + renderNavEntry(&lines, w, "历史对话", "#7dcfff", m.wsTabCur == i, focused) + continue + } + + g := Groups[i] + label := g.Label + gs := style.GroupStyles[g.Label] + isActive := m.wsTabCur == i + if isActive { + line := " ▸ " + label + line = padRightByWidth(truncateByWidth(line, w), w) + if focused && gs.GetForeground() != (lipgloss.Color("")) { + sty := lipgloss.NewStyle().Foreground(style.BgDark).Background(gs.GetForeground()).Bold(true) + lines = append(lines, sty.Render(line)) + } else if gs.GetForeground() != (lipgloss.Color("")) { + sty := lipgloss.NewStyle().Foreground(gs.GetForeground()).Bold(true) + lines = append(lines, sty.Render(line)) + } else { + lines = append(lines, style.SelStyle.Render(line)) + } + } else { + line := " " + label + line = padRightByWidth(truncateByWidth(line, w), w) + if gs.GetForeground() != (lipgloss.Color("")) { + lines = append(lines, gs.Render(line)) + } else { + lines = append(lines, style.NormStyle.Render(line)) + } + } + } + return lines +} + +func (m *Model) wsListLines(g Group, svcs []Workspace, w, listH int) []string { + start, end := viewport(m.cursor, len(svcs), listH) - // 计算 title 最大宽度用于对齐 maxTitleW := 0 for _, ws := range svcs { if tw := stringWidth(ws.Title); tw > maxTitleW { @@ -553,6 +697,7 @@ func (m *Model) renderWorkspace() string { } } + var lines []string for i := start; i < end; i++ { ws := svcs[i] cur := " " @@ -567,84 +712,70 @@ func (m *Model) renderWorkspace() string { paddedTitle := padRightByWidth(ws.Title, maxTitleW) if i == m.cursor { - // 选中行:纯文本,让 SelStyle 统一着色 prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " " - remainW := max(10, innerW-len(prefix)) + remainW := max(10, w-stringWidth(prefix)) text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW) - left.WriteString(style.SelStyle.Width(innerW).Render(prefix + text)) + sty := style.SelStyle + if m.wsFocus != 1 { + sty = lipgloss.NewStyle().Foreground(style.Accent).Bold(true) + } + lines = append(lines, sty.Render(padRightByWidth(truncateByWidth(prefix+text, w), w))) } else { - // 非选中行:子样式着色 markStr := " " if m.selected[ws.Index] { markStr = style.MarkStyle.Render("✓") } num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N)) prefix := cur + " " + markStr + " " + num + " " - remainW := max(10, innerW-lipgloss.Width(prefix)) + remainW := max(10, w-stringWidth(prefix)) text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW) - left.WriteString(style.NormStyle.Render(prefix + text)) + lines = append(lines, style.NormStyle.Render(padRightByWidth(truncateByWidth(prefix+text, w), w))) } - left.WriteString("\n") + } + return lines +} + +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))} } - groupHeader := gs.Render(fmt.Sprintf(" %s · %d ", g.Label, len(svcs))) - listBox := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(style.BgPanel). - Width(listW). - Padding(0, 1). - Render(groupHeader + "\n" + left.String()) + ws := svcs[m.cursor] + sepSty := lipgloss.NewStyle().Foreground(style.BgPanel) + var lines []string - // 右栏: 详情 - var right strings.Builder - if m.cursor < len(svcs) { - ws := svcs[m.cursor] + lines = append(lines, style.DetailTitle.Render(fitWidth(" "+ws.Title, w))) + lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) - right.WriteString(style.DetailTitle.Render(" detail ")) - right.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", detailW-10))) - right.WriteString("\n\n") - - rows := []struct { - key string - val string - sty lipgloss.Style - }{ - {"dir", ws.Dir, style.ValStyle}, - {"no", fmt.Sprintf("%02d · %s", ws.N, ws.Group), style.NumStyle}, - {"desc", ws.Prompt, style.ValStyle}, - {"tech", ws.Tech, style.TechStyle}, - {"deploy", ws.Deploy, style.DeployStyle}, - } - for _, r := range rows { - right.WriteString(" ") - right.WriteString(style.KeyStyle.Render(r.key)) - right.WriteString(" ") - right.WriteString(r.sty.Render(r.val)) - right.WriteString("\n") - } - - right.WriteString("\n") - right.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(" " + strings.Repeat("─", detailW-6))) - right.WriteString("\n") - hintCmd := "wt" - if runtime.GOOS != "windows" { - hintCmd = "bash" - } - right.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(" $ "+hintCmd+" → CC @"+ws.Title)) - right.WriteString("\n") - right.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" [Enter]start [Space]multi")) - } else { - right.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" ← select to view")) + rows := []struct { + key string + val string + sty lipgloss.Style + }{ + {"dir", ws.Dir, style.ValStyle}, + {"no", fmt.Sprintf("%02d · %s", ws.N, ws.Group), style.NumStyle}, + {"desc", ws.Prompt, style.ValStyle}, + {"tech", ws.Tech, style.TechStyle}, + {"deploy", ws.Deploy, style.DeployStyle}, + } + for _, r := range rows { + line := fmt.Sprintf(" %-6s %s", r.key, r.sty.Render(r.val)) + lines = append(lines, style.NormStyle.Render(fitWidth(line, w))) } - detailBox := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(style.BgPanel). - Width(detailW). - Padding(0, 1). - Render(right.String()) + lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) - return lipgloss.JoinHorizontal(lipgloss.Left, listBox, " ", detailBox) + 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))) + + if len(lines) > listH { + lines = lines[:listH] + } + return lines } // --- HISTORY 三栏渲染 --- @@ -660,7 +791,6 @@ func (m *Model) renderHistory() string { return style.SubtitleStyle.Render(" no sessions found") } - // 三个独立面板,Width() 含边框,内容宽度 = Width - 2 avail := m.width - 8 dirW := max(18, avail*20/100) sessW := max(28, avail*40/100) @@ -675,35 +805,28 @@ func (m *Model) renderHistory() string { sessLines := m.sessColumnLines(sessCw, listH-1) detailLines := m.detailColumnLines(detailCw, listH-1) - // 统一高度:补空行到相同行数 maxRows := min(listH-1, max(max(len(dirLines), len(sessLines)), len(detailLines))) - padLines := func(lines []string, w int) string { - for len(lines) < maxRows { - lines = append(lines, strings.Repeat(" ", w)) - } - return strings.Join(lines, "\n") - } panelSty := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(style.BgPanel) dirBox := panelSty.Width(dirW).Render( - style.DetailTitle.Render("dirs") + "\n" + padLines(dirLines, dirCw)) + style.DetailTitle.Render("dirs") + "\n" + padLinesTo(dirLines, dirCw, maxRows)) sessBox := panelSty.Width(sessW).Render( - style.DetailTitle.Render("sessions") + "\n" + padLines(sessLines, sessCw)) + style.DetailTitle.Render("sessions") + "\n" + padLinesTo(sessLines, sessCw, maxRows)) detailBox := panelSty.Width(detailW).Render( - style.DetailTitle.Render("detail") + "\n" + padLines(detailLines, detailCw)) + style.DetailTitle.Render("detail") + "\n" + padLinesTo(detailLines, detailCw, maxRows)) return lipgloss.JoinHorizontal(lipgloss.Top, dirBox, " ", sessBox, " ", detailBox) } func (m *Model) dirColumnLines(w, listH int) []string { var lines []string - total := len(m.history.Projects) + 1 + total := len(m.history.Projects) + 3 // 全部 + 收藏 + 项目 + 回到工作空间 start, end := viewport(m.history.DirCursor, total, listH) - prefixW := 3 // " " + cur(1) + " " + prefixW := 3 buildDirLine := func(cur, name, cnt string, selected bool) string { cntW := stringWidth(cnt) @@ -717,6 +840,7 @@ func (m *Model) dirColumnLines(w, listH int) []string { return sty.Render(padRightByWidth(truncateByWidth(line, w), w)) } + // 0: 全部 if start == 0 { totalSess := 0 for _, pd := range m.history.Projects { @@ -730,17 +854,36 @@ func (m *Model) dirColumnLines(w, listH int) []string { m.history.DirCursor == 0 && m.history.FocusPanel == 0)) } + // 1: 收藏 + if start <= 1 && end > 1 { + favCnt := countFavorites(m.history.Projects, m.history.Favorites) + cur := " " + if m.history.DirCursor == 1 { + cur = "▸" + } + lines = append(lines, buildDirLine(cur, "★ 收藏", fmt.Sprintf("(%d)", favCnt), + m.history.DirCursor == 1 && m.history.FocusPanel == 0)) + } + + // 2..N+1: 项目目录 for i := 0; i < len(m.history.Projects); i++ { - if i+1 < start || i+1 >= end { + idx := i + 2 + if idx < start || idx >= end { continue } pd := m.history.Projects[i] cur := " " - if m.history.DirCursor == i+1 { + if m.history.DirCursor == idx { cur = "▸" } lines = append(lines, buildDirLine(cur, pd.DirShort, fmt.Sprintf("(%d)", len(pd.Sessions)), - m.history.DirCursor == i+1 && m.history.FocusPanel == 0)) + m.history.DirCursor == idx && m.history.FocusPanel == 0)) + } + + // N+2: 回到工作空间入口 + backIdx := len(m.history.Projects) + 2 + if end > backIdx { + renderNavEntry(&lines, w, "工作空间", "#7aa2f7", m.history.DirCursor == backIdx, m.history.FocusPanel == 0) } return lines } @@ -751,10 +894,9 @@ func (m *Model) sessColumnLines(w, listH int) []string { return []string{style.SubtitleStyle.Render(fitWidth(" no sessions", w))} } - prefixW := 15 // " " + cur(2) + timeStr(11) + " " + prefixW := 16 textW := max(8, w-prefixW) - // 预渲染每个 session 为 1~N 行 type sessBlock struct { lines []string } @@ -768,6 +910,10 @@ func (m *Model) sessColumnLines(w, listH int) []string { cur = "▸ " } timeStr := s.StartTime.Local().Format("01-02 15:04") + favMark := " " + if m.history.Favorites[s.ID] { + favMark = "★" + } title := s.DisplayTitle() wrapped := wrapByWidth(title, textW) @@ -784,9 +930,9 @@ func (m *Model) sessColumnLines(w, listH int) []string { for li, part := range wrapped { var line string if li == 0 { - line = fmt.Sprintf(" %s%s %s", cur, timeStr, part) + line = fmt.Sprintf(" %s%s%s %s", cur, timeStr, favMark, part) } else { - line = strings.Repeat(" ", prefixW) + part + line = strings.Repeat(" ", prefixW+1) + part } blockLines = append(blockLines, sty.Render(padRightByWidth(truncateByWidth(line, w), w))) } @@ -798,7 +944,6 @@ func (m *Model) sessColumnLines(w, listH int) []string { blocks = append(blocks, sessBlock{lines: blockLines}) } - // 按总行数做 viewport startLine, endLine := viewport(curLineOffset, totalLines, listH) var allLines []string @@ -820,30 +965,33 @@ func (m *Model) detailColumnLines(w, listH int) []string { sepSty := lipgloss.NewStyle().Foreground(style.BgPanel) var lines []string - // Title - lines = append(lines, style.DetailTitle.Render(fitWidth(" "+s.DisplayTitle(), w))) - - // Separator + favPrefix := " " + if m.history.Favorites[s.ID] { + favPrefix = "★ " + } + lines = append(lines, style.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w))) lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) - // Key-value rows + fav := "-" + if m.history.Favorites[s.ID] { + fav = "★" + } kvRows := []struct{ k, v string }{ {"dir", s.Cwd}, {"time", fmt.Sprintf("%s ~ %s", s.StartTime.Local().Format("01-02 15:04"), s.EndTime.Local().Format("15:04"))}, {"msgs", fmt.Sprintf("%d", s.MsgCount)}, + {"fav", fav}, } for _, r := range kvRows { line := fmt.Sprintf(" %-4s %s", r.k, r.v) lines = append(lines, style.NormStyle.Render(fitWidth(line, w))) } - // Summary if summary := s.DisplaySummary(); summary != "" { lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) lines = append(lines, style.SessionSummaryStyle.Render(fitWidth(" "+summary, w))) } - // Completed items 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))) @@ -852,7 +1000,6 @@ func (m *Model) detailColumnLines(w, listH int) []string { } } - // Pending items 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))) @@ -861,7 +1008,6 @@ func (m *Model) detailColumnLines(w, listH int) []string { } } - // Hint lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]resume", w))) if len(lines) > listH { @@ -877,7 +1023,13 @@ func (m *Model) fmtHelp(key, desc string) string { lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc) } -// viewport 返回 [start, end) 区间,保证 cursor 在可见范围内 +func padLinesTo(lines []string, w, target int) string { + for len(lines) < target { + lines = append(lines, strings.Repeat(" ", w)) + } + return strings.Join(lines, "\n") +} + func viewport(cursor, total, height int) (int, int) { if total <= height { return 0, total @@ -898,7 +1050,6 @@ func viewport(cursor, total, height int) (int, int) { return start, end } -// stringWidth 返回字符串的终端显示宽度 func stringWidth(s string) int { w := 0 for _, r := range s { @@ -907,7 +1058,6 @@ func stringWidth(s string) int { return w } -// padRightByWidth 补空格到指定视觉宽度 func padRightByWidth(s string, targetW int) string { w := stringWidth(s) if w >= targetW { @@ -916,7 +1066,6 @@ func padRightByWidth(s string, targetW int) string { return s + strings.Repeat(" ", targetW-w) } -// wrapByWidth 按显示宽度换行,返回多行文本 func wrapByWidth(s string, maxW int) []string { if maxW <= 0 || s == "" { return []string{s} @@ -930,12 +1079,28 @@ func wrapByWidth(s string, maxW int) []string { return lines } -// fitWidth 截断并填充到精确视觉宽度 func fitWidth(s string, w int) string { return padRightByWidth(truncateByWidth(s, w), w) } -// truncateByWidth 按显示宽度截断字符串,不切断多字节字符 +func renderNavEntry(lines *[]string, w int, label string, activeFg string, isCur, focused bool) { + sep := lipgloss.NewStyle().Foreground(style.BgPanel).Render(fitWidth(" ─────────", w)) + *lines = append(*lines, sep) + if isCur { + curLabel := " → " + label + line := padRightByWidth(truncateByWidth(curLabel, w), w) + if focused { + *lines = append(*lines, style.SelStyle.Render(line)) + } else { + *lines = append(*lines, lipgloss.NewStyle().Foreground(lipgloss.Color(activeFg)).Bold(true).Render(line)) + } + } else { + inactiveLabel := " " + label + line := padRightByWidth(truncateByWidth(inactiveLabel, w), w) + *lines = append(*lines, lipgloss.NewStyle().Foreground(style.Dim).Render(line)) + } +} + func truncateByWidth(s string, maxW int) string { w := 0 for i := 0; i < len(s); { @@ -950,7 +1115,6 @@ func truncateByWidth(s string, maxW int) string { return s } -// runeWidth 返回单个 rune 的终端显示宽度 (CJK=2, 其他=1) func runeWidth(r rune) int { if r >= 0x1100 && (r <= 0x115F || r == 0x2329 || r == 0x232A || @@ -969,38 +1133,35 @@ func runeWidth(r rune) int { } func launchInWtWithTitle(dir, tabTitle, script string) { - if runtime.GOOS == "windows" { - encoded := encodePSCommand(script) - color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255)) - args := []string{"-w", "0", "-d", dir, "--tabColor", color} - if tabTitle != "" { - args = append(args, "--title", tabTitle) - } - args = append(args, "pwsh", "-NoExit", "-EncodedCommand", encoded) - cmd := exec.Command("wt.exe", args...) - if err := cmd.Start(); err != nil { - log.Printf("[u-tabs] launch fail: %v", err) - } + encoded := encodePSCommand(script) + color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255)) + args := []string{"-w", "0", "-d", dir, "--tabColor", color} + if tabTitle != "" { + args = append(args, "--title", tabTitle) + } + args = append(args, "pwsh", "-NoExit", "-EncodedCommand", encoded) + cmd := exec.Command("wt.exe", args...) + if err := cmd.Start(); err != nil { + log.Printf("[u-tabs] launch fail: %v", err) } } func (m *Model) LaunchWorkspace(ws Workspace) { script := buildLaunchScript(ws) - if runtime.GOOS != "windows" { + if !isWindows { m.pendingCmd = "cd '" + ws.Dir + "' && " + script return } launchInWtWithTitle(ws.Dir, "", script) } -// GetPendingCmd 返回退出后需执行的命令(Linux 用) func (m *Model) GetPendingCmd() string { return m.pendingCmd } func resumeSession(s *Session) { cwd := s.Cwd - if runtime.GOOS == "windows" { + if isWindows { cwd = strings.ReplaceAll(s.Cwd, "/", "\\") } title := s.CustomTitle @@ -1008,11 +1169,11 @@ func resumeSession(s *Session) { title = s.ID[:8] } var script string - if runtime.GOOS == "windows" { + if isWindows { script = fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "resume: %s" -Write-Host "=== Resuming: %s ===" -ForegroundColor Cyan -cd "%s" -claude -r %s --permission-mode bypassPermissions`, title, title, cwd, s.ID) + Write-Host "=== Resuming: %s ===" -ForegroundColor Cyan + cd "%s" + claude -r %s --permission-mode bypassPermissions`, title, title, cwd, s.ID) } else { script = fmt.Sprintf(`printf '\033]0;resume: %s\007' && echo "=== Resuming: %s ===" && cd "%s" && claude -r %s`, title, title, cwd, s.ID) } @@ -1020,12 +1181,12 @@ claude -r %s --permission-mode bypassPermissions`, title, title, cwd, s.ID) } func buildLaunchScript(ws Workspace) string { - if runtime.GOOS == "windows" { + if isWindows { return fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s" -Write-Host "=== %s ===" -ForegroundColor Cyan -Write-Host "Prompt: %s" -ForegroundColor Yellow -cd "%s" -claude --name "%s" --permission-mode bypassPermissions`, + Write-Host "=== %s ===" -ForegroundColor Cyan + Write-Host "Prompt: %s" -ForegroundColor Yellow + cd "%s" + claude --name "%s" --permission-mode bypassPermissions`, ws.Title, ws.Title, ws.Prompt, ws.Dir, ws.Title) } return fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ===" && echo "Prompt: %s" && cd "%s" && claude --name "%s"`, @@ -1046,4 +1207,3 @@ func randRange(min, max int) int { n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min))) return min + int(n.Int64()) } - diff --git a/internal/history.go b/internal/history.go index 478793a..a2c9e6d 100644 --- a/internal/history.go +++ b/internal/history.go @@ -88,6 +88,7 @@ type HistoryState struct { FocusPanel int // 0=左栏 1=中栏 Loaded bool Scanning bool + Favorites map[string]bool } // IsHistoryTab 判断当前是否在 HISTORY Tab @@ -203,7 +204,6 @@ func scanSessionFile(path string, sessionID string) *Session { scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - lineCount := 0 firstMsgFound := false @@ -551,3 +551,76 @@ func saveSummaryToCache(sessionID, summary string, completed, pending []string) entry.Pending = pending saveCache(home, cache) } + +// --- 收藏 --- + +func favoritesPath(home string) string { + return filepath.Join(home, ".u-tabs", "favorites.json") +} + +func loadFavorites() map[string]bool { + home, err := os.UserHomeDir() + if err != nil { + return make(map[string]bool) + } + data, err := os.ReadFile(favoritesPath(home)) + if err != nil { + return make(map[string]bool) + } + var ids []string + if json.Unmarshal(data, &ids) != nil { + return make(map[string]bool) + } + m := make(map[string]bool, len(ids)) + for _, id := range ids { + m[id] = true + } + return m +} + +func saveFavorites(favs map[string]bool) { + home, err := os.UserHomeDir() + if err != nil { + return + } + ids := make([]string, 0, len(favs)) + for id := range favs { + ids = append(ids, id) + } + data, err := json.Marshal(ids) + if err != nil { + return + } + dir := filepath.Join(home, ".u-tabs") + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Printf("[u-tabs] favorites mkdir fail: %v", err) + return + } + if err := os.WriteFile(favoritesPath(home), data, 0o644); err != nil { + log.Printf("[u-tabs] favorites write fail: %v", err) + } +} + +func countFavorites(projects []*ProjectDir, favs map[string]bool) int { + n := 0 + for _, pd := range projects { + for _, s := range pd.Sessions { + if favs[s.ID] { + n++ + } + } + } + return n +} + +func collectFavorites(projects []*ProjectDir, favs map[string]bool) []*Session { + var out []*Session + for _, pd := range projects { + for _, s := range pd.Sessions { + if favs[s.ID] { + out = append(out, s) + } + } + } + return out +}