1050 lines
26 KiB
Go
1050 lines
26 KiB
Go
package internal
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"fmt"
|
||
"log"
|
||
"math/big"
|
||
"os/exec"
|
||
"runtime"
|
||
"strconv"
|
||
"path/filepath"
|
||
"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
|
||
pendingCmd string // Linux: 退出后执行的命令
|
||
update UpdateState
|
||
}
|
||
|
||
func NewModel() *Model {
|
||
return &Model{
|
||
activeGroup: 0,
|
||
selected: make(map[int]bool),
|
||
}
|
||
}
|
||
|
||
func (m *Model) Init() tea.Cmd {
|
||
m.update.Checking = true
|
||
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:
|
||
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
|
||
total := 0
|
||
for _, pd := range msg.Projects {
|
||
total += len(pd.Sessions)
|
||
}
|
||
m.launched = fmt.Sprintf("refreshed: %d projects, %d sessions", len(msg.Projects), total)
|
||
if len(msg.UpdatedIDs) > 0 {
|
||
return m, m.nextSummaryCmd()
|
||
}
|
||
return m, nil
|
||
case SummaryResultMsg:
|
||
m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending)
|
||
return m, m.nextSummaryCmd()
|
||
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
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// --- 按键分发 ---
|
||
|
||
func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
|
||
// 全局按键
|
||
switch s {
|
||
case "q", "ctrl+c":
|
||
return m, tea.Quit
|
||
case "u":
|
||
if m.update.Available && !m.update.Updating {
|
||
m.update.Updating = true
|
||
return m, SelfUpdateCmd(m.update.DownloadURL, m.update.SHA256, m.update.NewVersion)
|
||
}
|
||
}
|
||
|
||
// 数字键跳转 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()
|
||
}
|
||
case "n":
|
||
return m.newSessionFromHistory()
|
||
case "r", "f5":
|
||
m.history.Scanning = true
|
||
return m, ScanSessionsCmd()
|
||
}
|
||
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) + 1 // 含"全部"
|
||
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
|
||
}
|
||
if runtime.GOOS == "windows" {
|
||
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.launched = "resume: " + s.CustomTitle
|
||
return m, nil
|
||
}
|
||
|
||
func (m *Model) newSessionFromHistory() (*Model, tea.Cmd) {
|
||
s := m.currentSession()
|
||
if s == nil {
|
||
return m, nil
|
||
}
|
||
cwd := s.Cwd
|
||
if runtime.GOOS == "windows" {
|
||
cwd = strings.ReplaceAll(cwd, "/", "\\")
|
||
}
|
||
title := filepath.Base(cwd) + " - new"
|
||
if runtime.GOOS == "windows" {
|
||
script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s"
|
||
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)
|
||
}
|
||
m.launched = "new: " + filepath.Base(cwd)
|
||
return m, nil
|
||
}
|
||
|
||
|
||
// --- AI 摘要 ---
|
||
|
||
func (m *Model) applySummary(sessionID, summary string, completed, pending []string) {
|
||
if summary == "" {
|
||
return
|
||
}
|
||
for _, pd := range m.history.Projects {
|
||
for _, s := range pd.Sessions {
|
||
if s.ID == sessionID {
|
||
s.AISummary = summary
|
||
s.Completed = completed
|
||
s.Pending = pending
|
||
saveSummaryToCache(sessionID, summary, completed, pending)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (m *Model) nextSummaryCmd() tea.Cmd {
|
||
for _, pd := range m.history.Projects {
|
||
for _, s := range pd.Sessions {
|
||
needSummary := s.AISummary == "" && s.FirstMsg != ""
|
||
needDetail := s.AISummary != "" && len(s.Completed) == 0 && len(s.Pending) == 0
|
||
if (needSummary || needDetail) && s.FilePath != "" {
|
||
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]
|
||
m.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]
|
||
m.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 {
|
||
m.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 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))
|
||
}
|
||
|
||
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 = ""
|
||
}
|
||
// update status
|
||
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 {
|
||
b.WriteString(lipgloss.NewStyle().Foreground(style.Red).
|
||
Render(fmt.Sprintf(" update failed: %v", m.update.Error)))
|
||
} else if m.update.Updating {
|
||
b.WriteString(lipgloss.NewStyle().Foreground(style.Warning).
|
||
Render(" updating..."))
|
||
} 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()
|
||
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("n", "new"),
|
||
m.fmtHelp("1-"+fmt.Sprintf("%d", m.totalTabs()), "tab"),
|
||
m.fmtHelp("r/F5", "refresh"),
|
||
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")
|
||
}
|
||
|
||
// 预留 header(2) + footer(2),面板边框+标题占 3 行
|
||
availH := max(3, m.height-4-3)
|
||
start, end := viewport(m.cursor, len(svcs), availH)
|
||
|
||
// 左栏: 列表
|
||
var left strings.Builder
|
||
innerW := listW - 4
|
||
|
||
// 计算 title 最大宽度用于对齐
|
||
maxTitleW := 0
|
||
for _, ws := range svcs {
|
||
if tw := stringWidth(ws.Title); tw > maxTitleW {
|
||
maxTitleW = tw
|
||
}
|
||
}
|
||
|
||
for i := start; i < end; i++ {
|
||
ws := svcs[i]
|
||
cur := " "
|
||
if i == m.cursor {
|
||
cur = "▸"
|
||
}
|
||
mark := " "
|
||
if m.selected[ws.Index] {
|
||
mark = "✓"
|
||
}
|
||
|
||
paddedTitle := padRightByWidth(ws.Title, maxTitleW)
|
||
|
||
if i == m.cursor {
|
||
// 选中行:纯文本,让 SelStyle 统一着色
|
||
prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " "
|
||
remainW := max(10, innerW-len(prefix))
|
||
text := truncateByWidth(paddedTitle+" "+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(paddedTitle+" "+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")
|
||
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"))
|
||
}
|
||
|
||
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.Loaded && !m.history.Scanning {
|
||
return style.ScanningStyle.Render(" press Tab to load sessions...")
|
||
}
|
||
if len(m.history.Projects) == 0 && m.history.Scanning {
|
||
return style.ScanningStyle.Render(" scanning sessions...")
|
||
}
|
||
if len(m.history.Projects) == 0 {
|
||
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)
|
||
detailW := avail - dirW - sessW
|
||
listH := max(3, m.height-7)
|
||
|
||
dirCw := dirW - 2
|
||
sessCw := sessW - 2
|
||
detailCw := detailW - 2
|
||
|
||
dirLines := m.dirColumnLines(dirCw, listH-1)
|
||
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))
|
||
sessBox := panelSty.Width(sessW).Render(
|
||
style.DetailTitle.Render("sessions") + "\n" + padLines(sessLines, sessCw))
|
||
detailBox := panelSty.Width(detailW).Render(
|
||
style.DetailTitle.Render("detail") + "\n" + padLines(detailLines, detailCw))
|
||
|
||
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
|
||
start, end := viewport(m.history.DirCursor, total, listH)
|
||
|
||
prefixW := 3 // " " + cur(1) + " "
|
||
|
||
buildDirLine := func(cur, name, cnt string, selected bool) string {
|
||
cntW := stringWidth(cnt)
|
||
nameW := max(2, w-prefixW-cntW)
|
||
name = truncateByWidth(name, nameW)
|
||
line := " " + cur + " " + padRightByWidth(name, nameW) + cnt
|
||
sty := style.NormStyle
|
||
if selected {
|
||
sty = style.SelStyle
|
||
}
|
||
return sty.Render(padRightByWidth(truncateByWidth(line, w), w))
|
||
}
|
||
|
||
if start == 0 {
|
||
totalSess := 0
|
||
for _, pd := range m.history.Projects {
|
||
totalSess += len(pd.Sessions)
|
||
}
|
||
cur := " "
|
||
if m.history.DirCursor == 0 {
|
||
cur = "▸"
|
||
}
|
||
lines = append(lines, buildDirLine(cur, "全部", fmt.Sprintf("(%d)", totalSess),
|
||
m.history.DirCursor == 0 && m.history.FocusPanel == 0))
|
||
}
|
||
|
||
for i := 0; i < len(m.history.Projects); i++ {
|
||
if i+1 < start || i+1 >= end {
|
||
continue
|
||
}
|
||
pd := m.history.Projects[i]
|
||
cur := " "
|
||
if m.history.DirCursor == i+1 {
|
||
cur = "▸"
|
||
}
|
||
lines = append(lines, buildDirLine(cur, pd.DirShort, fmt.Sprintf("(%d)", len(pd.Sessions)),
|
||
m.history.DirCursor == i+1 && m.history.FocusPanel == 0))
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func (m *Model) sessColumnLines(w, listH int) []string {
|
||
sessions := m.currentSessions()
|
||
if len(sessions) == 0 {
|
||
return []string{style.SubtitleStyle.Render(fitWidth(" no sessions", w))}
|
||
}
|
||
|
||
prefixW := 15 // " " + cur(2) + timeStr(11) + " "
|
||
textW := max(8, w-prefixW)
|
||
|
||
// 预渲染每个 session 为 1~N 行
|
||
type sessBlock struct {
|
||
lines []string
|
||
}
|
||
var blocks []sessBlock
|
||
totalLines := 0
|
||
curLineOffset := 0
|
||
|
||
for i, s := range sessions {
|
||
cur := " "
|
||
if i == m.history.SessCursor {
|
||
cur = "▸ "
|
||
}
|
||
timeStr := s.StartTime.Local().Format("01-02 15:04")
|
||
title := s.DisplayTitle()
|
||
|
||
wrapped := wrapByWidth(title, textW)
|
||
if len(wrapped) == 0 {
|
||
wrapped = []string{""}
|
||
}
|
||
|
||
sty := style.NormStyle
|
||
if i == m.history.SessCursor && m.history.FocusPanel == 1 {
|
||
sty = style.SelStyle
|
||
}
|
||
|
||
var blockLines []string
|
||
for li, part := range wrapped {
|
||
var line string
|
||
if li == 0 {
|
||
line = fmt.Sprintf(" %s%s %s", cur, timeStr, part)
|
||
} else {
|
||
line = strings.Repeat(" ", prefixW) + part
|
||
}
|
||
blockLines = append(blockLines, sty.Render(padRightByWidth(truncateByWidth(line, w), w)))
|
||
}
|
||
|
||
if i == m.history.SessCursor {
|
||
curLineOffset = totalLines
|
||
}
|
||
totalLines += len(blockLines)
|
||
blocks = append(blocks, sessBlock{lines: blockLines})
|
||
}
|
||
|
||
// 按总行数做 viewport
|
||
startLine, endLine := viewport(curLineOffset, totalLines, listH)
|
||
|
||
var allLines []string
|
||
for _, b := range blocks {
|
||
allLines = append(allLines, b.lines...)
|
||
}
|
||
if endLine > len(allLines) {
|
||
endLine = len(allLines)
|
||
}
|
||
return allLines[startLine:endLine]
|
||
}
|
||
|
||
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))}
|
||
}
|
||
|
||
sepSty := lipgloss.NewStyle().Foreground(style.BgPanel)
|
||
var lines []string
|
||
|
||
// Title
|
||
lines = append(lines, style.DetailTitle.Render(fitWidth(" "+s.DisplayTitle(), w)))
|
||
|
||
// Separator
|
||
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||
|
||
// Key-value rows
|
||
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)},
|
||
}
|
||
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)))
|
||
for _, item := range s.Completed {
|
||
lines = append(lines, style.NormStyle.Render(fitWidth(" · "+cleanText(item), w)))
|
||
}
|
||
}
|
||
|
||
// 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)))
|
||
for _, item := range s.Pending {
|
||
lines = append(lines, style.NormStyle.Render(fitWidth(" · "+cleanText(item), w)))
|
||
}
|
||
}
|
||
|
||
// Hint
|
||
lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]resume", w)))
|
||
|
||
if len(lines) > listH {
|
||
lines = lines[:listH]
|
||
}
|
||
return lines
|
||
}
|
||
|
||
// --- 工具 ---
|
||
|
||
func (m *Model) fmtHelp(key, desc string) string {
|
||
return lipgloss.NewStyle().Foreground(style.Accent).Render(key) +
|
||
lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc)
|
||
}
|
||
|
||
// viewport 返回 [start, end) 区间,保证 cursor 在可见范围内
|
||
func viewport(cursor, total, height int) (int, int) {
|
||
if total <= height {
|
||
return 0, total
|
||
}
|
||
half := height / 2
|
||
start := cursor - half
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
end := start + height
|
||
if end > total {
|
||
end = total
|
||
start = end - height
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
}
|
||
return start, end
|
||
}
|
||
|
||
// stringWidth 返回字符串的终端显示宽度
|
||
func stringWidth(s string) int {
|
||
w := 0
|
||
for _, r := range s {
|
||
w += runeWidth(r)
|
||
}
|
||
return w
|
||
}
|
||
|
||
// padRightByWidth 补空格到指定视觉宽度
|
||
func padRightByWidth(s string, targetW int) string {
|
||
w := stringWidth(s)
|
||
if w >= targetW {
|
||
return s
|
||
}
|
||
return s + strings.Repeat(" ", targetW-w)
|
||
}
|
||
|
||
// wrapByWidth 按显示宽度换行,返回多行文本
|
||
func wrapByWidth(s string, maxW int) []string {
|
||
if maxW <= 0 || s == "" {
|
||
return []string{s}
|
||
}
|
||
var lines []string
|
||
for s != "" {
|
||
cut := truncateByWidth(s, maxW)
|
||
lines = append(lines, cut)
|
||
s = s[len(cut):]
|
||
}
|
||
return lines
|
||
}
|
||
|
||
// fitWidth 截断并填充到精确视觉宽度
|
||
func fitWidth(s string, w int) string {
|
||
return padRightByWidth(truncateByWidth(s, w), w)
|
||
}
|
||
|
||
// truncateByWidth 按显示宽度截断字符串,不切断多字节字符
|
||
func truncateByWidth(s string, maxW int) string {
|
||
w := 0
|
||
for i := 0; i < len(s); {
|
||
r, size := utf8.DecodeRuneInString(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 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (m *Model) LaunchWorkspace(ws Workspace) {
|
||
script := buildLaunchScript(ws)
|
||
if runtime.GOOS != "windows" {
|
||
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" {
|
||
cwd = strings.ReplaceAll(s.Cwd, "/", "\\")
|
||
}
|
||
title := s.CustomTitle
|
||
if title == "" {
|
||
title = s.ID[:8]
|
||
}
|
||
var script string
|
||
if runtime.GOOS == "windows" {
|
||
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)
|
||
} else {
|
||
script = fmt.Sprintf(`printf '\033]0;resume: %s\007' && echo "=== Resuming: %s ===" && cd "%s" && claude -r %s`, title, title, cwd, s.ID)
|
||
}
|
||
launchInWtWithTitle(cwd, "", script)
|
||
}
|
||
|
||
func buildLaunchScript(ws Workspace) string {
|
||
if runtime.GOOS == "windows" {
|
||
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)
|
||
}
|
||
return fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ===" && echo "Prompt: %s" && cd "%s" && claude --name "%s"`,
|
||
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())
|
||
}
|
||
|