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 } func NewModel() *Model { return &Model{ activeGroup: 0, selected: make(map[int]bool), } } func (m *Model) Init() tea.Cmd { return nil } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: s := msg.String() switch s { case "q", "ctrl+c": return m, tea.Quit case "tab", "right", "l": m.activeGroup = (m.activeGroup + 1) % len(Groups) m.cursor = 0 m.inputBuf = "" case "shift+tab", "left", "h": m.activeGroup = (m.activeGroup - 1 + len(Groups)) % len(Groups) m.cursor = 0 m.inputBuf = "" case "1", "2", "3", "4": idx, _ := strconv.Atoi(s) if idx <= len(Groups) { m.activeGroup = idx - 1 m.cursor = 0 m.inputBuf = "" } 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 } } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height } return m, nil } 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 } 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 // layout widths listW := max(42, min(65, m.width*55/100)) detailW := max(30, m.width-listW-3) // ── header: title + tabs ── b.WriteString(style.TitleStyle.Render(" u-tabs ")) sep := style.TabSep.Render(" | ") 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)) } } b.WriteString("\n") b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", m.width))) b.WriteString("\n") g := Groups[m.activeGroup] gs, _ := style.GroupStyles[g.Label] svcs := WorkspacesByGroup(g.Label) if len(svcs) == 0 { b.WriteString(style.SubtitleStyle.Render(" empty")) return b.String() } // ═══ left: list ═══ 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 = style.MarkStyle.Render("✓") } num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N)) prefix := cur + " " + mark + " " + num + " " remainW := max(10, innerW-lipgloss.Width(prefix)) text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW) line := prefix + text if i == m.cursor { left.WriteString(style.SelStyle.Width(innerW).Render(line)) } else { left.WriteString(style.NormStyle.Render(line)) } 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()) // ═══ right: detail ═══ 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()) b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, listBox, " ", detailBox)) // ── 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 := []string{ m.fmtHelp("j/k", "sel"), m.fmtHelp("Enter", "run"), m.fmtHelp("Space", "multi"), m.fmtHelp("Tab", "group"), m.fmtHelp("q", "quit"), } b.WriteString(" " + strings.Join(helpParts, " ")) if hint := ConfigHint(); hint != "" { b.WriteString("\n" + hint) } return 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 } // --- launch --- func launchWorkspace(ws Workspace) { script := buildLaunchScript(ws) 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", ws.Dir, "--tabColor", color, "pwsh", "-NoExit", "-EncodedCommand", encoded, ) if err := cmd.Start(); err != nil { log.Printf("[u-tabs] launch fail %s(%d): %v", ws.Title, ws.N, err) } } 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()) }