Files
u-tabs/internal/app.go

1210 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
var isWindows = runtime.GOOS == "windows"
// Model 主模型
type Model struct {
activeGroup int
cursor int
selected map[int]bool
inputBuf string
width int
height int
launched string
history HistoryState
pendingCmd string
update UpdateState
wsFocus int // workspace 焦点列: 0=tabs 1=list
wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口)
}
func NewModel() *Model {
return &Model{
activeGroup: 0,
selected: make(map[int]bool),
}
}
func (m *Model) Init() tea.Cmd {
m.update.Checking = true
return CheckUpdateCmd()
}
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
if m.history.Favorites == nil {
m.history.Favorites = loadFavorites()
}
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 快捷切换: 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
}
if m.IsHistoryTab() {
return m.handleHistoryKey(s)
}
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 按键 ---
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":
if m.wsFocus == 0 {
m.moveGroupCursor(-1)
} else {
m.moveCursor(-1)
}
case "down", "j":
if m.wsFocus == 0 {
m.moveGroupCursor(1)
} else {
m.moveCursor(1)
}
case "enter":
if m.inputBuf != "" {
return m.launchByInput()
}
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
}
}
return m, nil
}
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 {
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 {
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()
}
case "n":
return m.newSessionFromHistory()
case "r", "f5":
m.history.Scanning = true
return m, ScanSessionsCmd()
case "f":
m.toggleFavorite()
}
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) + 3 // 全部 + 收藏 + 项目 + 回到工作空间
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 m.history.DirCursor == 0 {
var all []*Session
for _, pd := range m.history.Projects {
all = append(all, pd.Sessions...)
}
return all
}
if m.history.DirCursor == 1 {
// 收藏过滤
return collectFavorites(m.history.Projects, m.history.Favorites)
}
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 {
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 isWindows {
go resumeSession(s)
} else {
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, 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()
cwd := ""
if s != nil {
cwd = s.Cwd
} else {
cwd = m.currentDirCwd()
}
if cwd == "" {
return m, nil
}
if isWindows {
cwd = strings.ReplaceAll(cwd, "/", "\\")
}
title := filepath.Base(cwd) + " - new"
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)
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
}
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) {
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
}
// 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..."
}
var b strings.Builder
// ── 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")
// ── 内容区 ──
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 = ""
}
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("←→", "panel"),
m.fmtHelp("Enter", "resume"),
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("2", "history"),
m.fmtHelp("q", "quit"),
}
}
// --- Workspace 三栏渲染 ---
func (m *Model) renderWorkspace() string {
g := Groups[m.activeGroup]
svcs := WorkspacesByGroup(g.Label)
if len(svcs) == 0 {
return style.SubtitleStyle.Render(" empty")
}
avail := m.width - 8
tabW := max(14, min(20, avail*18/100))
listW := max(30, avail*45/100)
detailW := avail - tabW - listW
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)
maxTitleW := 0
for _, ws := range svcs {
if tw := stringWidth(ws.Title); tw > maxTitleW {
maxTitleW = tw
}
}
var lines []string
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 {
prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " "
remainW := max(10, w-stringWidth(prefix))
text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW)
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, w-stringWidth(prefix))
text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW)
lines = append(lines, style.NormStyle.Render(padRightByWidth(truncateByWidth(prefix+text, w), w)))
}
}
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))}
}
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)))
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)))
}
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", 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)))
if len(lines) > listH {
lines = lines[:listH]
}
return lines
}
// --- 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")
}
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)))
panelSty := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(style.BgPanel)
dirBox := panelSty.Width(dirW).Render(
style.DetailTitle.Render("dirs") + "\n" + padLinesTo(dirLines, dirCw, maxRows))
sessBox := panelSty.Width(sessW).Render(
style.DetailTitle.Render("sessions") + "\n" + padLinesTo(sessLines, sessCw, maxRows))
detailBox := panelSty.Width(detailW).Render(
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) + 3 // 全部 + 收藏 + 项目 + 回到工作空间
start, end := viewport(m.history.DirCursor, total, listH)
prefixW := 3
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))
}
// 0: 全部
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))
}
// 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++ {
idx := i + 2
if idx < start || idx >= end {
continue
}
pd := m.history.Projects[i]
cur := " "
if m.history.DirCursor == idx {
cur = "▸"
}
lines = append(lines, buildDirLine(cur, pd.DirShort, fmt.Sprintf("(%d)", len(pd.Sessions)),
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
}
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 := 16
textW := max(8, w-prefixW)
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")
favMark := " "
if m.history.Favorites[s.ID] {
favMark = "★"
}
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 %s", cur, timeStr, favMark, part)
} else {
line = strings.Repeat(" ", prefixW+1) + 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})
}
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
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)))
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)))
}
if summary := s.DisplaySummary(); summary != "" {
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", 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)))
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)))
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)))
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)
}
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
}
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
}
func stringWidth(s string) int {
w := 0
for _, r := range s {
w += runeWidth(r)
}
return w
}
func padRightByWidth(s string, targetW int) string {
w := stringWidth(s)
if w >= targetW {
return s
}
return s + strings.Repeat(" ", targetW-w)
}
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
}
func fitWidth(s string, w int) string {
return padRightByWidth(truncateByWidth(s, w), w)
}
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); {
r, size := utf8.DecodeRuneInString(s[i:])
rw := runeWidth(r)
if w+rw > maxW {
return s[:i]
}
w += rw
i += size
}
return s
}
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) {
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 !isWindows {
m.pendingCmd = "cd '" + ws.Dir + "' && " + script
return
}
launchInWtWithTitle(ws.Dir, "", script)
}
func (m *Model) GetPendingCmd() string {
return m.pendingCmd
}
func resumeSession(s *Session) {
cwd := s.Cwd
if isWindows {
cwd = strings.ReplaceAll(s.Cwd, "/", "\\")
}
title := s.CustomTitle
if title == "" {
title = s.ID[:8]
}
var script string
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)
} 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 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`,
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())
}