From 36aeef4bb7585ea4aaa0841c37ec20d0f26a6beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 31 May 2026 21:40:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=88=86=E5=8F=89=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=A2=9E=E9=87=8F=E6=89=AB=E6=8F=8F=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 会话分叉: 按 c 键从历史会话分叉,支持带方向提示 - 启动自动加载历史记录 - 增量扫描: 缓存内存化、目录 modTime 跳过、已删除条目裁剪 - 刷新按钮复用 onTabSwitch 单一入口 --- .gitignore | 3 + internal/app.go | 1227 +---------------------------------------- internal/history.go | 48 ++ internal/key.go | 287 ++++++++++ internal/launch.go | 250 +++++++++ internal/update.go | 7 +- internal/util.go | 134 +++++ internal/view.go | 155 ++++++ internal/view_hist.go | 255 +++++++++ internal/view_ws.go | 189 +++++++ 10 files changed, 1327 insertions(+), 1228 deletions(-) create mode 100644 internal/key.go create mode 100644 internal/launch.go create mode 100644 internal/util.go create mode 100644 internal/view.go create mode 100644 internal/view_hist.go create mode 100644 internal/view_ws.go diff --git a/.gitignore b/.gitignore index e277b56..71a84e4 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ Thumbs.db # Claude .claude/ +*.exe +*.exe.old +*~ diff --git a/internal/app.go b/internal/app.go index 6c0cd32..40dee90 100644 --- a/internal/app.go +++ b/internal/app.go @@ -1,26 +1,11 @@ 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 @@ -33,9 +18,9 @@ type Model struct { history HistoryState pendingCmd string update UpdateState - wsFocus int // workspace 焦点列: 0=tabs 1=list - wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) - forkMode bool // 分叉输入模式 + wsFocus int // workspace 焦点列: 0=tabs 1=list + wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) + forkMode bool // 分叉输入模式 forkBuf string // 分叉方向提示输入 } @@ -113,1212 +98,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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.Scanning { - return nil - } - m.history.Scanning = true - return ScanSessionsCmd() -} - -// --- 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() - } - 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) { - // fork 输入模式拦截 - if m.forkMode { - return m.handleForkInput(s) - } - - 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": - if cmd := m.onTabSwitch(); cmd != nil { - return m, cmd - } - case "f": - m.toggleFavorite() - case "c": - if m.currentSession() != nil && m.history.FocusPanel == 1 { - m.forkMode = true - m.forkBuf = "" - } - } - return m, nil -} - -// handleForkInput 处理分叉方向提示输入 -func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) { - switch s { - case "enter": - return m.executeFork() - case "esc": - m.forkMode = false - m.forkBuf = "" - case "backspace": - if len(m.forkBuf) > 0 { - m.forkBuf = m.forkBuf[:len(m.forkBuf)-1] - } - default: - // 只接受可打印字符 - if len(s) == 1 && s[0] >= 32 && s[0] < 127 { - m.forkBuf += s - } - } - 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 -} - -func (m *Model) executeFork() (*Model, tea.Cmd) { - s := m.currentSession() - if s == nil { - m.forkMode = false - return m, nil - } - m.forkMode = false - - cwd := s.Cwd - if isWindows { - cwd = strings.ReplaceAll(cwd, "/", "\\") - } - title := s.CustomTitle - if title == "" { - title = s.ID[:8] - } - label := "fork: " + title - - // 构建 claude -r --fork-session [prompt] - if m.forkBuf != "" { - // 用户输入了方向提示 - prompt := m.forkBuf - m.forkBuf = "" - if isWindows { - escaped := strings.ReplaceAll(prompt, `"`, "`\"") - script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "fork: %s" - Write-Host "=== Fork from %s ===" -ForegroundColor Cyan - cd "%s" - claude -r %s --fork-session "%s" --permission-mode bypassPermissions`, title, title, cwd, s.ID, escaped) - launchInWtWithTitle(cwd, label, script) - } else { - escaped := strings.ReplaceAll(prompt, "'", "'\\''") - m.pendingCmd = fmt.Sprintf(`printf '\033]0;fork: %s\007' && echo "=== Fork from %s ===" && cd "%s" && claude -r %s --fork-session '%s' --permission-mode bypassPermissions`, title, title, cwd, s.ID, escaped) - } - } else { - // 纯分叉,不带方向提示 - if isWindows { - script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "fork: %s" - Write-Host "=== Fork from %s ===" -ForegroundColor Cyan - cd "%s" - claude -r %s --fork-session --permission-mode bypassPermissions`, title, title, cwd, s.ID) - launchInWtWithTitle(cwd, label, script) - } else { - m.pendingCmd = fmt.Sprintf(`printf '\033]0;fork: %s\007' && echo "=== Fork from %s ===" && cd "%s" && claude -r %s --fork-session --permission-mode bypassPermissions`, title, title, cwd, s.ID) - } - } - - m.launched = label - 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 - updateSummaryInCache(m.history.Cache, 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 ")) - 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.forkMode { - b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ⎇ fork:%s [Enter]confirm [Esc]cancel", m.forkBuf))) - } - 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() - helpStr := " " + strings.Join(helpParts, " ") - verStr := lipgloss.NewStyle().Foreground(style.Dim).Render("绝尘 v" + Version) - padW := m.width - lipgloss.Width(helpStr) - lipgloss.Width(verStr) - if padW > 0 { - b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr) - } else { - b.WriteString(helpStr + " " + verStr) - } - - if hint := ConfigHint(); hint != "" { - b.WriteString("\n" + hint) - } - return b.String() -} - -func (m *Model) renderHelp() []string { - if m.IsHistoryTab() { - return []string{ - m.fmtHelp("↑↓", "sel"), - m.fmtHelp("←→", "panel"), - m.fmtHelp("Enter", "resume"), - m.fmtHelp("f", "star"), - m.fmtHelp("n", "new"), - m.fmtHelp("c", "fork"), - m.fmtHelp("1", "workspace"), - m.fmtHelp("r/F5", "refresh"), - m.fmtHelp("q", "quit"), - } - } - return []string{ - m.fmtHelp("←→", "col"), - m.fmtHelp("↑↓", "sel"), - m.fmtHelp("Enter", "run"), - 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 s.ForkFrom != "" { - forkID := s.ForkFrom - if len(forkID) > 8 { - forkID = forkID[:8] - } - lines = append(lines, lipgloss.NewStyle().Foreground(style.Cyan).Render(fitWidth(fmt.Sprintf(" ⎇ fork from %s", forkID), 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()) -} diff --git a/internal/history.go b/internal/history.go index 06cd433..ce129fc 100644 --- a/internal/history.go +++ b/internal/history.go @@ -733,3 +733,51 @@ func recordFork(targetID, sourceID string) { os.MkdirAll(dir, 0o755) os.WriteFile(forksPath(home), data, 0o644) } + +// --- 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 + updateSummaryInCache(m.history.Cache, 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) +} diff --git a/internal/key.go b/internal/key.go new file mode 100644 index 0000000..daefac5 --- /dev/null +++ b/internal/key.go @@ -0,0 +1,287 @@ +package internal + +import ( + "charm.land/bubbletea/v2" +) + +// --- 按键分发 --- + +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.Scanning { + return nil + } + m.history.Scanning = true + return ScanSessionsCmd() +} + +// --- 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() + } + 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) { + if m.forkMode { + return m.handleForkInput(s) + } + + 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": + if cmd := m.onTabSwitch(); cmd != nil { + return m, cmd + } + case "f": + m.toggleFavorite() + case "c": + if m.currentSession() != nil && m.history.FocusPanel == 1 { + m.forkMode = true + m.forkBuf = "" + } + } + return m, nil +} + +func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) { + switch s { + case "enter": + return m.executeFork() + case "esc": + m.forkMode = false + m.forkBuf = "" + case "backspace": + if len(m.forkBuf) > 0 { + m.forkBuf = m.forkBuf[:len(m.forkBuf)-1] + } + default: + if len(s) == 1 && s[0] >= 32 && s[0] < 127 { + m.forkBuf += s + } + } + 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) 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) 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 + } + } +} diff --git a/internal/launch.go b/internal/launch.go new file mode 100644 index 0000000..56cb944 --- /dev/null +++ b/internal/launch.go @@ -0,0 +1,250 @@ +package internal + +import ( + "fmt" + "log" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "charm.land/bubbletea/v2" +) + +// SessionAction 描述一个 Claude Code 会话启动动作 +type SessionAction struct { + Cwd string // 工作目录 + Title string // 窗口标题 + SessionID string // resume/fork 目标(空=新建) + Fork bool // 是否分叉 + Prompt string // 初始提示(fork 方向或 workspace prompt) + Name string // --name 参数(workspace 启动用) +} + +// Execute 执行启动动作 +func (a SessionAction) Execute(m *Model) { + if isWindows { + cwd := toWinPath(a.Cwd) + launchInWtWithTitle(cwd, a.Title, a.buildScript()) + } else { + m.pendingCmd = a.buildScript() + } +} + +// buildScript 根据平台构建启动脚本 +func (a SessionAction) buildScript() string { + if isWindows { + return a.pwshScript() + } + return a.bashScript() +} + +func (a SessionAction) pwshScript() string { + var b strings.Builder + b.WriteString(fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s"`, a.Title)) + b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"=== %s ===\" -ForegroundColor Cyan", a.Title)) + + if a.Prompt != "" && a.Name == "" { + // workspace prompt 显示 + b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", a.Prompt)) + } + + b.WriteString(fmt.Sprintf("\n\t\tcd \"%s\"", toWinPath(a.Cwd))) + b.WriteString("\n\t\tclaude") + + if a.SessionID != "" { + b.WriteString(fmt.Sprintf(" -r %s", a.SessionID)) + if a.Fork { + b.WriteString(" --fork-session") + } + } + if a.Prompt != "" && a.SessionID != "" { + escaped := strings.ReplaceAll(a.Prompt, `"`, "`\"") + b.WriteString(fmt.Sprintf(` "%s"`, escaped)) + } + if a.Name != "" { + b.WriteString(fmt.Sprintf(` --name "%s"`, a.Name)) + } + b.WriteString(" --permission-mode bypassPermissions") + return b.String() +} + +func (a SessionAction) bashScript() string { + var b strings.Builder + b.WriteString(fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ==="`, a.Title, a.Title)) + + if a.Prompt != "" && a.Name == "" { + b.WriteString(fmt.Sprintf(` && echo "Prompt: %s"`, a.Prompt)) + } + + b.WriteString(fmt.Sprintf(` && cd "%s"`, a.Cwd)) + b.WriteString(" && claude") + + if a.SessionID != "" { + b.WriteString(fmt.Sprintf(" -r %s", a.SessionID)) + if a.Fork { + b.WriteString(" --fork-session") + } + } + if a.Prompt != "" && a.SessionID != "" { + escaped := strings.ReplaceAll(a.Prompt, "'", "'\\''") + b.WriteString(fmt.Sprintf(` '%s'`, escaped)) + } + if a.Name != "" { + b.WriteString(fmt.Sprintf(` --name "%s"`, a.Name)) + } + b.WriteString(" --permission-mode bypassPermissions") + return b.String() +} + +// --- 启动辅助 --- + +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) + } +} + +// buildResumeAction 构建恢复会话动作 +func buildResumeAction(s *Session) SessionAction { + title := s.CustomTitle + if title == "" { + title = s.ID[:8] + } + return SessionAction{ + Cwd: s.Cwd, + Title: "resume: " + title, + SessionID: s.ID, + } +} + +// buildForkAction 构建分叉会话动作 +func buildForkAction(s *Session, prompt string) SessionAction { + title := s.CustomTitle + if title == "" { + title = s.ID[:8] + } + return SessionAction{ + Cwd: s.Cwd, + Title: "fork: " + title, + SessionID: s.ID, + Fork: true, + Prompt: prompt, + } +} + +// buildNewAction 构建新建会话动作 +func buildNewAction(cwd string) SessionAction { + title := filepath.Base(cwd) + " - new" + return SessionAction{ + Cwd: cwd, + Title: title, + } +} + +// buildWorkspaceAction 构建工作空间启动动作 +func buildWorkspaceAction(ws Workspace) SessionAction { + return SessionAction{ + Cwd: ws.Dir, + Title: ws.Title, + Prompt: ws.Prompt, + Name: ws.Title, + } +} + +// --- 会话操作 --- + +func (m *Model) resumeSelected() (*Model, tea.Cmd) { + s := m.currentSession() + if s == nil { + return m, nil + } + action := buildResumeAction(s) + action.Execute(m) + m.launched = "resume: " + s.CustomTitle + return m, nil +} + +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 + } + action := buildNewAction(cwd) + action.Execute(m) + m.launched = "new: " + filepath.Base(cwd) + return m, nil +} + +func (m *Model) executeFork() (*Model, tea.Cmd) { + s := m.currentSession() + if s == nil { + m.forkMode = false + return m, nil + } + m.forkMode = false + + action := buildForkAction(s, m.forkBuf) + m.forkBuf = "" + action.Execute(m) + + title := s.CustomTitle + if title == "" { + title = s.ID[:8] + } + m.launched = "fork: " + title + return m, nil +} + +func (m *Model) launchSelected() (*Model, tea.Cmd) { + if len(m.selected) > 0 { + var launched []string + for idx := range m.selected { + ws := &AllWorkspaces[idx] + action := buildWorkspaceAction(*ws) + action.Execute(m) + 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] + action := buildWorkspaceAction(ws) + action.Execute(m) + 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 { + action := buildWorkspaceAction(*ws) + action.Execute(m) + m.launched = ws.Title + } + m.inputBuf = "" + return m, nil +} diff --git a/internal/update.go b/internal/update.go index 3cdc426..fb57705 100644 --- a/internal/update.go +++ b/internal/update.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "os" - "runtime" "strings" "time" @@ -15,7 +14,7 @@ import ( ) // Version 当前版本,发布时更新 -const Version = "0.1.1" +const Version = "0.2.0" // --- 远程 JSON 结构 --- @@ -103,7 +102,7 @@ func CheckUpdateCmd() tea.Cmd { } // 查找当前平台的下载信息 - platformKey := runtime.GOOS + "-" + runtime.GOARCH + platformKey := platformKey pi, ok := info.Platforms[platformKey] if !ok { return nil @@ -192,7 +191,7 @@ func SelfUpdateCmd(downloadURL, expectedSHA256, newVersion string) tea.Cmd { } // Linux/macOS: 设置可执行权限 - if runtime.GOOS != "windows" { + if !isWindows { os.Chmod(exePath, 0755) } diff --git a/internal/util.go b/internal/util.go new file mode 100644 index 0000000..6f67856 --- /dev/null +++ b/internal/util.go @@ -0,0 +1,134 @@ +package internal + +import ( + "crypto/rand" + "encoding/base64" + "math/big" + "runtime" + "strings" + "unicode/utf16" + "unicode/utf8" +) + +// 平台信息(启动时确定) +var ( + isWindows = runtime.GOOS == "windows" + platformKey = runtime.GOOS + "-" + runtime.GOARCH +) + +// toWinPath 将路径中的 / 替换为 \ +func toWinPath(s string) string { + return strings.ReplaceAll(s, "/", "\\") +} + +// --- 字符串宽度 --- + +func stringWidth(s string) int { + w := 0 + for _, r := range s { + w += runeWidth(r) + } + return w +} + +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 padRightByWidth(s string, targetW int) string { + w := stringWidth(s) + if w >= targetW { + return s + } + return s + strings.Repeat(" ", targetW-w) +} + +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 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 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 padLinesTo(lines []string, w, target int) string { + for len(lines) < target { + lines = append(lines, strings.Repeat(" ", w)) + } + return strings.Join(lines, "\n") +} + +// --- Windows Terminal --- + +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()) +} diff --git a/internal/view.go b/internal/view.go new file mode 100644 index 0000000..dc99038 --- /dev/null +++ b/internal/view.go @@ -0,0 +1,155 @@ +package internal + +import ( + "fmt" + "strings" + + "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "u-tabs/internal/style" +) + +// --- 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 + + 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.forkMode { + b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ⎇ fork:%s [Enter]confirm [Esc]cancel", m.forkBuf))) + } + 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() + helpStr := " " + strings.Join(helpParts, " ") + verStr := lipgloss.NewStyle().Foreground(style.Dim).Render("绝尘 v" + Version) + padW := m.width - lipgloss.Width(helpStr) - lipgloss.Width(verStr) + if padW > 0 { + b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr) + } else { + b.WriteString(helpStr + " " + verStr) + } + + if hint := ConfigHint(); hint != "" { + b.WriteString("\n" + hint) + } + return b.String() +} + +// renderTabBar 顶部两个主 tab +func (m *Model) renderTabBar() string { + var b strings.Builder + b.WriteString(style.TitleStyle.Render(" U-TABS ")) + sep := style.TabSep.Render(" │ ") + + 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)) + } + + 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) renderHelp() []string { + if m.IsHistoryTab() { + return []string{ + m.fmtHelp("↑↓", "sel"), + m.fmtHelp("←→", "panel"), + m.fmtHelp("Enter", "resume"), + m.fmtHelp("f", "star"), + m.fmtHelp("n", "new"), + m.fmtHelp("c", "fork"), + m.fmtHelp("1", "workspace"), + m.fmtHelp("r/F5", "refresh"), + m.fmtHelp("q", "quit"), + } + } + return []string{ + m.fmtHelp("←→", "col"), + m.fmtHelp("↑↓", "sel"), + m.fmtHelp("Enter", "run"), + m.fmtHelp("2", "history"), + m.fmtHelp("q", "quit"), + } +} + +func (m *Model) fmtHelp(key, desc string) string { + return lipgloss.NewStyle().Foreground(style.Accent).Render(key) + + lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc) +} + +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)) + } +} diff --git a/internal/view_hist.go b/internal/view_hist.go new file mode 100644 index 0000000..efab8c7 --- /dev/null +++ b/internal/view_hist.go @@ -0,0 +1,255 @@ +package internal + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "u-tabs/internal/style" +) + +// --- 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 s.ForkFrom != "" { + forkID := s.ForkFrom + if len(forkID) > 8 { + forkID = forkID[:8] + } + lines = append(lines, lipgloss.NewStyle().Foreground(style.Cyan).Render(fitWidth(fmt.Sprintf(" ⎇ fork from %s", forkID), w))) + } + + if len(lines) > listH { + lines = lines[:listH] + } + return lines +} diff --git a/internal/view_ws.go b/internal/view_ws.go new file mode 100644 index 0000000..23ee88f --- /dev/null +++ b/internal/view_ws.go @@ -0,0 +1,189 @@ +package internal + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "u-tabs/internal/style" +) + +// --- 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) +} + +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 +}