Files
u-tabs/internal/app.go
绝尘 52fa702e66 优化: dirs 面板增加"全部"选项
index 0 为"全部"合并显示所有目录的会话
2026-05-17 10:52:06 +08:00

809 lines
19 KiB
Go

package internal
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"math/big"
"os/exec"
"strconv"
"strings"
"unicode/utf16"
"unicode/utf8"
"charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"u-tabs/internal/style"
)
// Model 主模型
type Model struct {
activeGroup int
cursor int
selected map[int]bool
inputBuf string
width int
height int
launched string
history HistoryState
}
func NewModel() *Model {
return &Model{
activeGroup: 0,
selected: make(map[int]bool),
}
}
func (m *Model) Init() tea.Cmd {
return nil
}
// 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:
return m.handleKey(msg.String())
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case ScanCompleteMsg:
m.history.Projects = msg.Projects
m.history.Loaded = true
m.history.Scanning = false
return m, m.nextSummaryCmd()
case SummaryResultMsg:
m.applySummary(msg.SessionID, msg.Summary)
return m, m.nextSummaryCmd()
}
return m, nil
}
// --- 按键分发 ---
func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
// 全局按键
switch s {
case "q", "ctrl+c":
return m, tea.Quit
}
// 数字键跳转 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 = ""
return m, m.onTabSwitch()
}
}
// 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)
}
func (m *Model) onTabSwitch() tea.Cmd {
if m.IsHistoryTab() && !m.history.Loaded && !m.history.Scanning {
m.history.Scanning = true
return ScanSessionsCmd()
}
return nil
}
// --- Workspace Tab 按键 ---
func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) {
switch s {
case "up", "k":
m.moveCursor(-1)
case "down", "j":
m.moveCursor(1)
case " ":
m.toggleMultiSelect()
case "enter":
if m.inputBuf != "" {
return m.launchByInput()
}
return m.launchSelected()
default:
if len(s) == 1 && s[0] >= '0' && s[0] <= '9' {
m.inputBuf += s
}
}
return m, nil
}
// --- HISTORY Tab 按键 ---
func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
switch s {
case "up", "k":
if m.history.FocusPanel == 0 {
m.moveHistoryDir(-1)
} else {
m.moveHistorySess(-1)
}
case "down", "j":
if m.history.FocusPanel == 0 {
m.moveHistoryDir(1)
} else {
m.moveHistorySess(1)
}
case "tab", "right", "l":
if m.history.FocusPanel < 1 {
m.history.FocusPanel = 1
}
case "shift+tab", "left", "h":
if m.history.FocusPanel > 0 {
m.history.FocusPanel = 0
}
case "enter":
if m.history.FocusPanel == 0 {
// 在左栏按 enter 切到中栏
m.history.FocusPanel = 1
} else {
return m.resumeSelected()
}
}
return m, nil
}
func (m *Model) moveHistoryDir(dir int) {
if len(m.history.Projects) == 0 {
return
}
m.history.DirCursor += dir
maxDir := len(m.history.Projects) // 含"全部"
if m.history.DirCursor < 0 {
m.history.DirCursor = maxDir - 1
}
if m.history.DirCursor >= maxDir {
m.history.DirCursor = 0
}
m.history.SessCursor = 0
}
func (m *Model) moveHistorySess(dir int) {
sessions := m.currentSessions()
if len(sessions) == 0 {
return
}
m.history.SessCursor += dir
if m.history.SessCursor < 0 {
m.history.SessCursor = len(sessions) - 1
}
if m.history.SessCursor >= len(sessions) {
m.history.SessCursor = 0
}
}
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
}
return nil
}
func (m *Model) currentSession() *Session {
sessions := m.currentSessions()
if len(sessions) == 0 || m.history.SessCursor >= len(sessions) {
return nil
}
return sessions[m.history.SessCursor]
}
func (m *Model) resumeSelected() (*Model, tea.Cmd) {
s := m.currentSession()
if s == nil {
return m, nil
}
go resumeSession(s)
m.launched = "resume: " + s.CustomTitle
return m, nil
}
// --- AI 摘要 ---
func (m *Model) applySummary(sessionID, summary string) {
if summary == "" {
return
}
for _, pd := range m.history.Projects {
for _, s := range pd.Sessions {
if s.ID == sessionID {
s.AISummary = summary
saveSummaryToCache(sessionID, summary)
return
}
}
}
}
func (m *Model) nextSummaryCmd() tea.Cmd {
for _, pd := range m.history.Projects {
for _, s := range pd.Sessions {
if s.AISummary == "" && s.FilePath != "" && s.FirstMsg != "" {
return generateSummaryCmd(s.FilePath, s.ID)
}
}
}
return nil
}
// --- Workspace 光标 ---
func (m *Model) moveCursor(dir int) {
svcs := WorkspacesByGroup(Groups[m.activeGroup].Label)
if len(svcs) == 0 {
return
}
m.cursor += dir
if m.cursor < 0 {
m.cursor = len(svcs) - 1
}
if m.cursor >= len(svcs) {
m.cursor = 0
}
}
func (m *Model) toggleMultiSelect() {
svcs := WorkspacesByGroup(Groups[m.activeGroup].Label)
if m.cursor < len(svcs) {
idx := svcs[m.cursor].Index
if m.selected[idx] {
delete(m.selected, idx)
} else {
m.selected[idx] = true
}
}
}
func (m *Model) launchSelected() (*Model, tea.Cmd) {
if len(m.selected) > 0 {
var launched []string
for idx := range m.selected {
ws := &AllWorkspaces[idx]
go launchWorkspace(*ws)
launched = append(launched, ws.Title)
}
m.launched = strings.Join(launched, ", ")
m.selected = make(map[int]bool)
return m, nil
}
svcs := WorkspacesByGroup(Groups[m.activeGroup].Label)
if m.cursor < len(svcs) {
ws := svcs[m.cursor]
go launchWorkspace(ws)
m.launched = ws.Title
}
return m, nil
}
func (m *Model) launchByInput() (*Model, tea.Cmd) {
num, err := strconv.Atoi(m.inputBuf)
if err != nil {
m.inputBuf = ""
return m, nil
}
ws := FindByNumber(num)
if ws != nil {
go launchWorkspace(*ws)
m.launched = ws.Title
}
m.inputBuf = ""
return m, nil
}
// --- View ---
func (m *Model) View() tea.View {
v := tea.NewView(m.render())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.WindowTitle = "u-tabs"
return v
}
func (m *Model) render() string {
if m.width == 0 {
return "loading..."
}
var b strings.Builder
// ── header: title + tabs ──
b.WriteString(style.TitleStyle.Render(" u-tabs "))
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()).
Padding(0, 1).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()).
Padding(0, 1).Render(histLabel))
} else {
b.WriteString(style.TabInactiveStyle.Render(histLabel))
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", m.width)))
b.WriteString("\n")
// ── 内容区 ──
if m.IsHistoryTab() {
b.WriteString(m.renderHistory())
} else {
b.WriteString(m.renderWorkspace())
}
// ── footer ──
b.WriteString("\n")
if m.inputBuf != "" {
b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ▶ num:%s [Enter]go [Esc]cancel", m.inputBuf)))
}
if m.launched != "" {
b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched)))
m.launched = ""
}
b.WriteString("\n")
helpParts := m.renderHelp()
b.WriteString(" " + strings.Join(helpParts, " "))
if hint := ConfigHint(); hint != "" {
b.WriteString("\n" + hint)
}
return b.String()
}
func (m *Model) renderHelp() []string {
if m.IsHistoryTab() {
return []string{
m.fmtHelp("j/k", "sel"),
m.fmtHelp("Tab", "→panel"),
m.fmtHelp("Enter", "resume"),
m.fmtHelp("1-"+fmt.Sprintf("%d", m.totalTabs()), "tab"),
m.fmtHelp("q", "quit"),
}
}
return []string{
m.fmtHelp("j/k", "sel"),
m.fmtHelp("Enter", "run"),
m.fmtHelp("Space", "multi"),
m.fmtHelp("Tab", "group"),
m.fmtHelp("q", "quit"),
}
}
// --- 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")
}
// 左栏: 列表
var left strings.Builder
innerW := listW - 4
for i, ws := range svcs {
cur := " "
if i == m.cursor {
cur = "▸"
}
mark := " "
if m.selected[ws.Index] {
mark = "✓"
}
if i == m.cursor {
// 选中行:纯文本,让 SelStyle 统一着色
prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " "
remainW := max(10, innerW-len(prefix))
text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW)
left.WriteString(style.SelStyle.Width(innerW).Render(prefix + text))
} 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))
text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW)
left.WriteString(style.NormStyle.Render(prefix + text))
}
left.WriteString("\n")
}
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())
// 右栏: 详情
var right strings.Builder
if m.cursor < len(svcs) {
ws := svcs[m.cursor]
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")
right.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(" $ wt → 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"))
}
detailBox := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(style.BgPanel).
Width(detailW).
Padding(0, 1).
Render(right.String())
return lipgloss.JoinHorizontal(lipgloss.Left, listBox, " ", detailBox)
}
// --- HISTORY 三栏渲染 ---
func (m *Model) renderHistory() string {
if m.history.Scanning {
return style.ScanningStyle.Render(" scanning sessions...")
}
if !m.history.Loaded {
return style.ScanningStyle.Render(" press Tab to load sessions...")
}
if len(m.history.Projects) == 0 {
return style.SubtitleStyle.Render(" no sessions found")
}
// 三栏宽度分配
avail := m.width - 4 // borders + gaps
dirW := max(20, avail*20/100)
sessW := max(30, avail*40/100)
detailW := max(20, avail-dirW-sessW)
// 左栏: 目录列表
dirBox := m.renderDirPanel(dirW)
// 中栏: 会话列表
sessBox := m.renderSessPanel(sessW)
// 右栏: 详情
detailBox := m.renderHistoryDetail(detailW)
return lipgloss.JoinHorizontal(lipgloss.Left, dirBox, " ", sessBox, " ", detailBox)
}
func (m *Model) renderDirPanel(w int) string {
var b strings.Builder
innerW := w - 4
b.WriteString(style.DetailTitle.Render(" dirs "))
b.WriteString("\n")
// "全部" 行 (index 0)
totalSess := 0
for _, pd := range m.history.Projects {
totalSess += len(pd.Sessions)
}
cur := " "
if m.history.DirCursor == 0 {
cur = "▸"
}
if m.history.DirCursor == 0 && m.history.FocusPanel == 0 {
line := fmt.Sprintf("%s 全部 (%d)", cur, totalSess)
b.WriteString(style.SelStyle.Width(innerW).Render(line))
} else {
cnt := style.DirCountStyle.Render(fmt.Sprintf("(%d)", totalSess))
line := fmt.Sprintf("%s 全部 %s", cur, cnt)
b.WriteString(style.NormStyle.Render(line))
}
b.WriteString("`n")
for i, pd := range m.history.Projects {
cur := " "
if m.history.DirCursor == i+1 {
cur = "▸"
}
name := truncateByWidth(pd.DirShort, max(8, innerW-8))
if m.history.DirCursor == i+1 && m.history.FocusPanel == 0 {
line := fmt.Sprintf("%s %s (%d)", cur, name, len(pd.Sessions))
b.WriteString(style.SelStyle.Width(innerW).Render(line))
} else {
cnt := style.DirCountStyle.Render(fmt.Sprintf("(%d)", len(pd.Sessions)))
line := fmt.Sprintf("%s %s %s", cur, name, cnt)
b.WriteString(style.NormStyle.Render(line))
}
b.WriteString("`n")
}
return lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(style.BgPanel).
Width(w).
Padding(0, 1).
Render(b.String())
}
func (m *Model) renderSessPanel(w int) string {
var b strings.Builder
innerW := w - 4
b.WriteString(style.DetailTitle.Render(" sessions "))
b.WriteString("\n")
sessions := m.currentSessions()
if len(sessions) == 0 {
b.WriteString(style.SubtitleStyle.Render(" no sessions"))
} else {
for i, s := range sessions {
cur := " "
if i == m.history.SessCursor {
cur = "▸"
}
timeStr := s.StartTime.Format("01-02 15:04")
title := s.DisplayTitle()
remainW := max(10, innerW-10)
text := truncateByWidth(title, remainW)
if i == m.history.SessCursor && m.history.FocusPanel == 1 {
line := fmt.Sprintf("%s %s %s", cur, timeStr, text)
b.WriteString(style.SelStyle.Width(innerW).Render(line))
} else {
line := fmt.Sprintf("%s %s %s", cur, style.SessionTimeStyle.Render(timeStr), text)
b.WriteString(style.NormStyle.Render(line))
}
b.WriteString("\n")
}
}
return lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(style.BgPanel).
Width(w).
Padding(0, 1).
Render(b.String())
}
func (m *Model) renderHistoryDetail(w int) string {
var b strings.Builder
s := m.currentSession()
if s == nil {
b.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" ← select to view"))
} else {
b.WriteString(style.DetailTitle.Render(" detail "))
b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", w-12)))
b.WriteString("\n\n")
rows := []struct {
key string
val string
sty lipgloss.Style
}{
{"title", s.CustomTitle, style.ValStyle},
{"dir", s.Cwd, style.ValStyle},
{"time", fmt.Sprintf("%s ~ %s", s.StartTime.Format("01-02 15:04"), s.EndTime.Format("15:04")), style.NumStyle},
{"msgs", fmt.Sprintf("%d", s.MsgCount), style.SessionMsgCntStyle},
}
for _, r := range rows {
b.WriteString(" ")
b.WriteString(style.KeyStyle.Render(r.key))
b.WriteString(" ")
b.WriteString(r.sty.Render(r.val))
b.WriteString("\n")
}
// 摘要
summary := s.DisplaySummary()
if summary != "" {
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(" " + strings.Repeat("─", w-6)))
b.WriteString("\n")
b.WriteString(style.SessionSummaryStyle.Render(" " + truncateByWidth(summary, w-6)))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" [Enter]resume"))
}
return lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(style.BgPanel).
Width(w).
Padding(0, 1).
Render(b.String())
}
// --- 工具 ---
func (m *Model) fmtHelp(key, desc string) string {
return lipgloss.NewStyle().Foreground(style.Accent).Render(key) +
lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc)
}
// truncateByWidth 按显示宽度截断字符串,不切断多字节字符
func truncateByWidth(s string, maxW int) string {
w := 0
for i := 0; i < len(s); {
_, size := utf8.DecodeRuneInString(s[i:])
r := rune(s[i])
rw := runeWidth(r)
if w+rw > maxW {
return s[:i]
}
w += rw
i += size
}
return s
}
// runeWidth 返回单个 rune 的终端显示宽度 (CJK=2, 其他=1)
func runeWidth(r rune) int {
if r >= 0x1100 &&
(r <= 0x115F || r == 0x2329 || r == 0x232A ||
(r >= 0x2E80 && r <= 0xA4CF && r != 0x303F) ||
(r >= 0xAC00 && r <= 0xD7A3) ||
(r >= 0xF900 && r <= 0xFAFF) ||
(r >= 0xFE10 && r <= 0xFE19) ||
(r >= 0xFE30 && r <= 0xFE6F) ||
(r >= 0xFF01 && r <= 0xFF60) ||
(r >= 0xFFE0 && r <= 0xFFE6) ||
(r >= 0x20000 && r <= 0x2FFFD) ||
(r >= 0x30000 && r <= 0x3FFFD)) {
return 2
}
return 1
}
// --- 启动 ---
func launchInWt(dir, script string) {
encoded := encodePSCommand(script)
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255))
cmd := exec.Command("wt.exe", "-w", "0",
"-d", dir,
"--tabColor", color,
"pwsh", "-NoExit", "-EncodedCommand", encoded,
)
if err := cmd.Start(); err != nil {
log.Printf("[u-tabs] launch fail: %v", err)
}
}
func launchWorkspace(ws Workspace) {
script := buildLaunchScript(ws)
launchInWt(ws.Dir, script)
}
func resumeSession(s *Session) {
cwd := strings.ReplaceAll(s.Cwd, "/", "\\")
title := s.CustomTitle
if title == "" {
title = s.ID[:8]
}
script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "resume: %s"
Write-Host "=== Resuming: %s ===" -ForegroundColor Cyan
cd "%s"
claude -r %s`, title, title, cwd, s.ID)
launchInWt(cwd, script)
}
func buildLaunchScript(ws Workspace) string {
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`,
ws.Title, ws.Title, ws.Prompt, ws.Dir, ws.Title)
}
func encodePSCommand(script string) string {
u16 := utf16.Encode([]rune(script))
b := make([]byte, len(u16)*2)
for i, r := range u16 {
b[i*2] = byte(r)
b[i*2+1] = byte(r >> 8)
}
return base64.StdEncoding.EncodeToString(b)
}
func randRange(min, max int) int {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min)))
return min + int(n.Int64())
}