新增: 历史对话收藏功能,重构 Tab 导航与平台检查
This commit is contained in:
572
internal/app.go
572
internal/app.go
@@ -19,6 +19,8 @@ import (
|
|||||||
"u-tabs/internal/style"
|
"u-tabs/internal/style"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var isWindows = runtime.GOOS == "windows"
|
||||||
|
|
||||||
// Model 主模型
|
// Model 主模型
|
||||||
type Model struct {
|
type Model struct {
|
||||||
activeGroup int
|
activeGroup int
|
||||||
@@ -29,8 +31,10 @@ type Model struct {
|
|||||||
height int
|
height int
|
||||||
launched string
|
launched string
|
||||||
history HistoryState
|
history HistoryState
|
||||||
pendingCmd string // Linux: 退出后执行的命令
|
pendingCmd string
|
||||||
update UpdateState
|
update UpdateState
|
||||||
|
wsFocus int // workspace 焦点列: 0=tabs 1=list
|
||||||
|
wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel() *Model {
|
func NewModel() *Model {
|
||||||
@@ -45,11 +49,6 @@ func (m *Model) Init() tea.Cmd {
|
|||||||
return CheckUpdateCmd()
|
return CheckUpdateCmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
// totalTabs 包含 HISTORY 的总 Tab 数
|
|
||||||
func (m *Model) totalTabs() int {
|
|
||||||
return len(Groups) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
@@ -61,6 +60,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.history.Projects = msg.Projects
|
m.history.Projects = msg.Projects
|
||||||
m.history.Loaded = true
|
m.history.Loaded = true
|
||||||
m.history.Scanning = false
|
m.history.Scanning = false
|
||||||
|
if m.history.Favorites == nil {
|
||||||
|
m.history.Favorites = loadFavorites()
|
||||||
|
}
|
||||||
total := 0
|
total := 0
|
||||||
for _, pd := range msg.Projects {
|
for _, pd := range msg.Projects {
|
||||||
total += len(pd.Sessions)
|
total += len(pd.Sessions)
|
||||||
@@ -96,7 +98,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// --- 按键分发 ---
|
// --- 按键分发 ---
|
||||||
|
|
||||||
func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
|
func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
|
||||||
// 全局按键
|
|
||||||
switch s {
|
switch s {
|
||||||
case "q", "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -107,36 +108,28 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 数字键跳转 Tab
|
// Tab 快捷切换: 1=工作空间 2=历史对话
|
||||||
if len(s) == 1 && s[0] >= '1' && s[0] <= '9' {
|
if s == "1" {
|
||||||
idx, _ := strconv.Atoi(s)
|
if m.IsHistoryTab() {
|
||||||
if idx <= m.totalTabs() {
|
m.activeGroup = 0
|
||||||
m.activeGroup = idx - 1
|
m.resetCursor()
|
||||||
m.cursor = 0
|
m.wsFocus = 1
|
||||||
m.inputBuf = ""
|
}
|
||||||
|
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, m.onTabSwitch()
|
||||||
}
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HISTORY Tab: tab/left/right 用于面板切换
|
|
||||||
if m.IsHistoryTab() {
|
if m.IsHistoryTab() {
|
||||||
return m.handleHistoryKey(s)
|
return m.handleHistoryKey(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workspace Tab 按键
|
|
||||||
switch s {
|
|
||||||
case "tab", "right", "l":
|
|
||||||
m.activeGroup = (m.activeGroup + 1) % m.totalTabs()
|
|
||||||
m.cursor = 0
|
|
||||||
m.inputBuf = ""
|
|
||||||
return m, m.onTabSwitch()
|
|
||||||
case "shift+tab", "left", "h":
|
|
||||||
m.activeGroup = (m.activeGroup - 1 + m.totalTabs()) % m.totalTabs()
|
|
||||||
m.cursor = 0
|
|
||||||
m.inputBuf = ""
|
|
||||||
return m, m.onTabSwitch()
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.handleWorkspaceKey(s)
|
return m.handleWorkspaceKey(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,21 +141,51 @@ func (m *Model) onTabSwitch() tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Workspace Tab 按键 ---
|
// --- Workspace 按键 ---
|
||||||
|
|
||||||
func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) {
|
func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) {
|
||||||
switch s {
|
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":
|
case "up", "k":
|
||||||
|
if m.wsFocus == 0 {
|
||||||
|
m.moveGroupCursor(-1)
|
||||||
|
} else {
|
||||||
m.moveCursor(-1)
|
m.moveCursor(-1)
|
||||||
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
|
if m.wsFocus == 0 {
|
||||||
|
m.moveGroupCursor(1)
|
||||||
|
} else {
|
||||||
m.moveCursor(1)
|
m.moveCursor(1)
|
||||||
case " ":
|
}
|
||||||
m.toggleMultiSelect()
|
|
||||||
case "enter":
|
case "enter":
|
||||||
if m.inputBuf != "" {
|
if m.inputBuf != "" {
|
||||||
return m.launchByInput()
|
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()
|
return m.launchSelected()
|
||||||
|
}
|
||||||
|
case " ":
|
||||||
|
if m.wsFocus == 1 {
|
||||||
|
m.toggleMultiSelect()
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
if len(s) == 1 && s[0] >= '0' && s[0] <= '9' {
|
if len(s) == 1 && s[0] >= '0' && s[0] <= '9' {
|
||||||
m.inputBuf += s
|
m.inputBuf += s
|
||||||
@@ -171,7 +194,20 @@ func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HISTORY Tab 按键 ---
|
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) {
|
func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
||||||
switch s {
|
switch s {
|
||||||
@@ -197,7 +233,13 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case "enter":
|
case "enter":
|
||||||
if m.history.FocusPanel == 0 {
|
if m.history.FocusPanel == 0 {
|
||||||
// 在左栏按 enter 切到中栏
|
if m.history.DirCursor == len(m.history.Projects)+2 {
|
||||||
|
// 回到工作空间
|
||||||
|
m.activeGroup = 0
|
||||||
|
m.resetCursor()
|
||||||
|
m.wsTabCur = 0
|
||||||
|
m.wsFocus = 1
|
||||||
|
}
|
||||||
m.history.FocusPanel = 1
|
m.history.FocusPanel = 1
|
||||||
} else {
|
} else {
|
||||||
return m.resumeSelected()
|
return m.resumeSelected()
|
||||||
@@ -207,6 +249,8 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
|||||||
case "r", "f5":
|
case "r", "f5":
|
||||||
m.history.Scanning = true
|
m.history.Scanning = true
|
||||||
return m, ScanSessionsCmd()
|
return m, ScanSessionsCmd()
|
||||||
|
case "f":
|
||||||
|
m.toggleFavorite()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -216,7 +260,7 @@ func (m *Model) moveHistoryDir(dir int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.history.DirCursor += dir
|
m.history.DirCursor += dir
|
||||||
maxDir := len(m.history.Projects) + 1 // 含"全部"
|
maxDir := len(m.history.Projects) + 3 // 全部 + 收藏 + 项目 + 回到工作空间
|
||||||
if m.history.DirCursor < 0 {
|
if m.history.DirCursor < 0 {
|
||||||
m.history.DirCursor = maxDir - 1
|
m.history.DirCursor = maxDir - 1
|
||||||
}
|
}
|
||||||
@@ -241,66 +285,82 @@ func (m *Model) moveHistorySess(dir int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) currentSessions() []*Session {
|
func (m *Model) currentSessions() []*Session {
|
||||||
if len(m.history.Projects) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if m.history.DirCursor == 0 {
|
if m.history.DirCursor == 0 {
|
||||||
// "全部" — 合并所有目录的会话
|
|
||||||
var all []*Session
|
var all []*Session
|
||||||
for _, pd := range m.history.Projects {
|
for _, pd := range m.history.Projects {
|
||||||
all = append(all, pd.Sessions...)
|
all = append(all, pd.Sessions...)
|
||||||
}
|
}
|
||||||
return all
|
return all
|
||||||
}
|
}
|
||||||
idx := m.history.DirCursor - 1
|
if m.history.DirCursor == 1 {
|
||||||
if idx < len(m.history.Projects) {
|
// 收藏过滤
|
||||||
return m.history.Projects[idx].Sessions
|
return collectFavorites(m.history.Projects, m.history.Favorites)
|
||||||
}
|
}
|
||||||
|
idx := m.history.DirCursor - 2
|
||||||
|
if idx >= len(m.history.Projects) {
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
return m.history.Projects[idx].Sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) currentSession() *Session {
|
func (m *Model) currentSession() *Session {
|
||||||
sessions := m.currentSessions()
|
sessions := m.currentSessions()
|
||||||
if len(sessions) == 0 || m.history.SessCursor >= len(sessions) {
|
if len(sessions) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if m.history.SessCursor >= len(sessions) {
|
||||||
|
m.history.SessCursor = len(sessions) - 1
|
||||||
|
}
|
||||||
return sessions[m.history.SessCursor]
|
return sessions[m.history.SessCursor]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) resumeSelected() (*Model, tea.Cmd) {
|
func (m *Model) resumeSelected() (*Model, tea.Cmd) {
|
||||||
s := m.currentSession()
|
s := m.currentSession()
|
||||||
if s == nil {
|
if isWindows {
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
go resumeSession(s)
|
go resumeSession(s)
|
||||||
} else {
|
} else {
|
||||||
cwd := s.Cwd
|
|
||||||
title := s.CustomTitle
|
title := s.CustomTitle
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = s.ID[:8]
|
title = s.ID[:8]
|
||||||
}
|
}
|
||||||
m.pendingCmd = fmt.Sprintf(`printf '\033]0;resume: %s\007' && echo "=== Resuming: %s ===" && cd "%s" && claude -r %s`, title, title, cwd, s.ID)
|
m.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
|
m.launched = "resume: " + s.CustomTitle
|
||||||
return m, nil
|
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) {
|
func (m *Model) newSessionFromHistory() (*Model, tea.Cmd) {
|
||||||
s := m.currentSession()
|
s := m.currentSession()
|
||||||
if s == nil {
|
cwd := ""
|
||||||
|
if s != nil {
|
||||||
|
cwd = s.Cwd
|
||||||
|
} else {
|
||||||
|
cwd = m.currentDirCwd()
|
||||||
|
}
|
||||||
|
if cwd == "" {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
cwd := s.Cwd
|
if isWindows {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
cwd = strings.ReplaceAll(cwd, "/", "\\")
|
cwd = strings.ReplaceAll(cwd, "/", "\\")
|
||||||
}
|
}
|
||||||
title := filepath.Base(cwd) + " - new"
|
title := filepath.Base(cwd) + " - new"
|
||||||
if runtime.GOOS == "windows" {
|
if isWindows {
|
||||||
script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s"
|
script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s"
|
||||||
Write-Host "=== New Session @ %s ===" -ForegroundColor Cyan
|
Write-Host "=== New Session @ %s ===" -ForegroundColor Cyan
|
||||||
cd "%s"
|
cd "%s"
|
||||||
claude --permission-mode bypassPermissions`, title, cwd, cwd)
|
claude --permission-mode bypassPermissions`, title, cwd, cwd)
|
||||||
launchInWtWithTitle(cwd, title, script)
|
launchInWtWithTitle(cwd, title, script)
|
||||||
} else {
|
} else {
|
||||||
m.pendingCmd = fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== New Session @ %s ===" && cd "%s" && claude --permission-mode bypassPermissions`, title, cwd, cwd)
|
m.pendingCmd = fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== New Session @ %s ===" && cd "%s" && claude --permission-mode bypassPermissions`, title, cwd, cwd)
|
||||||
@@ -309,7 +369,6 @@ claude --permission-mode bypassPermissions`, title, cwd, cwd)
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- AI 摘要 ---
|
// --- AI 摘要 ---
|
||||||
|
|
||||||
func (m *Model) applySummary(sessionID, summary string, completed, pending []string) {
|
func (m *Model) applySummary(sessionID, summary string, completed, pending []string) {
|
||||||
@@ -341,6 +400,23 @@ func (m *Model) nextSummaryCmd() tea.Cmd {
|
|||||||
}
|
}
|
||||||
return nil
|
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 光标 ---
|
// --- Workspace 光标 ---
|
||||||
|
|
||||||
func (m *Model) moveCursor(dir int) {
|
func (m *Model) moveCursor(dir int) {
|
||||||
@@ -415,6 +491,36 @@ func (m *Model) View() tea.View {
|
|||||||
return v
|
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 {
|
func (m *Model) render() string {
|
||||||
if m.width == 0 {
|
if m.width == 0 {
|
||||||
return "loading..."
|
return "loading..."
|
||||||
@@ -422,42 +528,8 @@ func (m *Model) render() string {
|
|||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
// ── header: title + tabs ──
|
// ── header: tab 导航条 + separator ──
|
||||||
b.WriteString(style.TitleStyle.Render(" u-tabs v" + Version + " "))
|
b.WriteString(m.renderTabBar())
|
||||||
sep := style.TabSep.Render(" | ")
|
|
||||||
|
|
||||||
// Workspace Tabs
|
|
||||||
for i, g := range Groups {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString(sep)
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf(" %s %s ", g.Label, g.Desc)
|
|
||||||
if i == m.activeGroup {
|
|
||||||
gs, ok := style.GroupStyles[g.Label]
|
|
||||||
if ok {
|
|
||||||
b.WriteString(lipgloss.NewStyle().
|
|
||||||
Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()).
|
|
||||||
Render(label))
|
|
||||||
} else {
|
|
||||||
b.WriteString(style.TabActiveStyle.Render(label))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b.WriteString(style.TabInactiveStyle.Render(label))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HISTORY Tab
|
|
||||||
b.WriteString(sep)
|
|
||||||
histLabel := " HISTORY 历史 "
|
|
||||||
if m.IsHistoryTab() {
|
|
||||||
gs := style.GroupStyles["HISTORY"]
|
|
||||||
b.WriteString(lipgloss.NewStyle().
|
|
||||||
Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()).
|
|
||||||
Render(histLabel))
|
|
||||||
} else {
|
|
||||||
b.WriteString(style.TabInactiveStyle.Render(histLabel))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", m.width)))
|
b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", m.width)))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
@@ -478,7 +550,6 @@ func (m *Model) render() string {
|
|||||||
b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched)))
|
b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched)))
|
||||||
m.launched = ""
|
m.launched = ""
|
||||||
}
|
}
|
||||||
// update status
|
|
||||||
if m.update.Done {
|
if m.update.Done {
|
||||||
b.WriteString(lipgloss.NewStyle().Foreground(style.Success).Bold(true).
|
b.WriteString(lipgloss.NewStyle().Foreground(style.Success).Bold(true).
|
||||||
Render(fmt.Sprintf(" updated to v%s, restart to apply", m.update.NewVersion)))
|
Render(fmt.Sprintf(" updated to v%s, restart to apply", m.update.NewVersion)))
|
||||||
@@ -507,45 +578,118 @@ func (m *Model) renderHelp() []string {
|
|||||||
if m.IsHistoryTab() {
|
if m.IsHistoryTab() {
|
||||||
return []string{
|
return []string{
|
||||||
m.fmtHelp("j/k", "sel"),
|
m.fmtHelp("j/k", "sel"),
|
||||||
m.fmtHelp("Tab", "→panel"),
|
m.fmtHelp("←→", "panel"),
|
||||||
m.fmtHelp("Enter", "resume"),
|
m.fmtHelp("Enter", "resume"),
|
||||||
|
m.fmtHelp("f", "star"),
|
||||||
m.fmtHelp("n", "new"),
|
m.fmtHelp("n", "new"),
|
||||||
m.fmtHelp("1-"+fmt.Sprintf("%d", m.totalTabs()), "tab"),
|
m.fmtHelp("1", "workspace"),
|
||||||
m.fmtHelp("r/F5", "refresh"),
|
m.fmtHelp("r/F5", "refresh"),
|
||||||
m.fmtHelp("q", "quit"),
|
m.fmtHelp("q", "quit"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return []string{
|
return []string{
|
||||||
|
m.fmtHelp("←→", "col"),
|
||||||
m.fmtHelp("j/k", "sel"),
|
m.fmtHelp("j/k", "sel"),
|
||||||
m.fmtHelp("Enter", "run"),
|
m.fmtHelp("Enter", "run"),
|
||||||
m.fmtHelp("Space", "multi"),
|
m.fmtHelp("Space", "multi"),
|
||||||
m.fmtHelp("Tab", "group"),
|
m.fmtHelp("2", "history"),
|
||||||
m.fmtHelp("q", "quit"),
|
m.fmtHelp("q", "quit"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Workspace 渲染 ---
|
// --- Workspace 三栏渲染 ---
|
||||||
|
|
||||||
func (m *Model) renderWorkspace() string {
|
func (m *Model) renderWorkspace() string {
|
||||||
listW := max(42, min(65, m.width*55/100))
|
|
||||||
detailW := max(30, m.width-listW-3)
|
|
||||||
|
|
||||||
g := Groups[m.activeGroup]
|
g := Groups[m.activeGroup]
|
||||||
gs, _ := style.GroupStyles[g.Label]
|
|
||||||
svcs := WorkspacesByGroup(g.Label)
|
svcs := WorkspacesByGroup(g.Label)
|
||||||
if len(svcs) == 0 {
|
if len(svcs) == 0 {
|
||||||
return style.SubtitleStyle.Render(" empty")
|
return style.SubtitleStyle.Render(" empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预留 header(2) + footer(2),面板边框+标题占 3 行
|
avail := m.width - 8
|
||||||
availH := max(3, m.height-4-3)
|
tabW := max(14, min(20, avail*18/100))
|
||||||
start, end := viewport(m.cursor, len(svcs), availH)
|
listW := max(30, avail*45/100)
|
||||||
|
detailW := avail - tabW - listW
|
||||||
|
|
||||||
// 左栏: 列表
|
tabCw := tabW - 2
|
||||||
var left strings.Builder
|
listCw := listW - 2
|
||||||
innerW := listW - 4
|
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)
|
||||||
|
|
||||||
// 计算 title 最大宽度用于对齐
|
|
||||||
maxTitleW := 0
|
maxTitleW := 0
|
||||||
for _, ws := range svcs {
|
for _, ws := range svcs {
|
||||||
if tw := stringWidth(ws.Title); tw > maxTitleW {
|
if tw := stringWidth(ws.Title); tw > maxTitleW {
|
||||||
@@ -553,6 +697,7 @@ func (m *Model) renderWorkspace() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
ws := svcs[i]
|
ws := svcs[i]
|
||||||
cur := " "
|
cur := " "
|
||||||
@@ -567,42 +712,40 @@ func (m *Model) renderWorkspace() string {
|
|||||||
paddedTitle := padRightByWidth(ws.Title, maxTitleW)
|
paddedTitle := padRightByWidth(ws.Title, maxTitleW)
|
||||||
|
|
||||||
if i == m.cursor {
|
if i == m.cursor {
|
||||||
// 选中行:纯文本,让 SelStyle 统一着色
|
|
||||||
prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " "
|
prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " "
|
||||||
remainW := max(10, innerW-len(prefix))
|
remainW := max(10, w-stringWidth(prefix))
|
||||||
text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW)
|
text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW)
|
||||||
left.WriteString(style.SelStyle.Width(innerW).Render(prefix + text))
|
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 {
|
} else {
|
||||||
// 非选中行:子样式着色
|
|
||||||
markStr := " "
|
markStr := " "
|
||||||
if m.selected[ws.Index] {
|
if m.selected[ws.Index] {
|
||||||
markStr = style.MarkStyle.Render("✓")
|
markStr = style.MarkStyle.Render("✓")
|
||||||
}
|
}
|
||||||
num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N))
|
num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N))
|
||||||
prefix := cur + " " + markStr + " " + num + " "
|
prefix := cur + " " + markStr + " " + num + " "
|
||||||
remainW := max(10, innerW-lipgloss.Width(prefix))
|
remainW := max(10, w-stringWidth(prefix))
|
||||||
text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW)
|
text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW)
|
||||||
left.WriteString(style.NormStyle.Render(prefix + text))
|
lines = append(lines, style.NormStyle.Render(padRightByWidth(truncateByWidth(prefix+text, w), w)))
|
||||||
}
|
}
|
||||||
left.WriteString("\n")
|
}
|
||||||
|
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))}
|
||||||
}
|
}
|
||||||
|
|
||||||
groupHeader := gs.Render(fmt.Sprintf(" %s · %d ", g.Label, len(svcs)))
|
|
||||||
listBox := lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.NormalBorder()).
|
|
||||||
BorderForeground(style.BgPanel).
|
|
||||||
Width(listW).
|
|
||||||
Padding(0, 1).
|
|
||||||
Render(groupHeader + "\n" + left.String())
|
|
||||||
|
|
||||||
// 右栏: 详情
|
|
||||||
var right strings.Builder
|
|
||||||
if m.cursor < len(svcs) {
|
|
||||||
ws := svcs[m.cursor]
|
ws := svcs[m.cursor]
|
||||||
|
sepSty := lipgloss.NewStyle().Foreground(style.BgPanel)
|
||||||
|
var lines []string
|
||||||
|
|
||||||
right.WriteString(style.DetailTitle.Render(" detail "))
|
lines = append(lines, style.DetailTitle.Render(fitWidth(" "+ws.Title, w)))
|
||||||
right.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", detailW-10)))
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
right.WriteString("\n\n")
|
|
||||||
|
|
||||||
rows := []struct {
|
rows := []struct {
|
||||||
key string
|
key string
|
||||||
@@ -616,35 +759,23 @@ func (m *Model) renderWorkspace() string {
|
|||||||
{"deploy", ws.Deploy, style.DeployStyle},
|
{"deploy", ws.Deploy, style.DeployStyle},
|
||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
right.WriteString(" ")
|
line := fmt.Sprintf(" %-6s %s", r.key, r.sty.Render(r.val))
|
||||||
right.WriteString(style.KeyStyle.Render(r.key))
|
lines = append(lines, style.NormStyle.Render(fitWidth(line, w)))
|
||||||
right.WriteString(" ")
|
|
||||||
right.WriteString(r.sty.Render(r.val))
|
|
||||||
right.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
right.WriteString("\n")
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
right.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(" " + strings.Repeat("─", detailW-6)))
|
|
||||||
right.WriteString("\n")
|
|
||||||
hintCmd := "wt"
|
hintCmd := "wt"
|
||||||
if runtime.GOOS != "windows" {
|
if !isWindows {
|
||||||
hintCmd = "bash"
|
hintCmd = "bash"
|
||||||
}
|
}
|
||||||
right.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(" $ "+hintCmd+" → CC @"+ws.Title))
|
lines = append(lines, lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(fitWidth(" $ "+hintCmd+" → CC @"+ws.Title, w)))
|
||||||
right.WriteString("\n")
|
lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]start [Space]multi", w)))
|
||||||
right.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" [Enter]start [Space]multi"))
|
|
||||||
} else {
|
if len(lines) > listH {
|
||||||
right.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" ← select to view"))
|
lines = lines[:listH]
|
||||||
}
|
}
|
||||||
|
return lines
|
||||||
detailBox := lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.NormalBorder()).
|
|
||||||
BorderForeground(style.BgPanel).
|
|
||||||
Width(detailW).
|
|
||||||
Padding(0, 1).
|
|
||||||
Render(right.String())
|
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Left, listBox, " ", detailBox)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HISTORY 三栏渲染 ---
|
// --- HISTORY 三栏渲染 ---
|
||||||
@@ -660,7 +791,6 @@ func (m *Model) renderHistory() string {
|
|||||||
return style.SubtitleStyle.Render(" no sessions found")
|
return style.SubtitleStyle.Render(" no sessions found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 三个独立面板,Width() 含边框,内容宽度 = Width - 2
|
|
||||||
avail := m.width - 8
|
avail := m.width - 8
|
||||||
dirW := max(18, avail*20/100)
|
dirW := max(18, avail*20/100)
|
||||||
sessW := max(28, avail*40/100)
|
sessW := max(28, avail*40/100)
|
||||||
@@ -675,35 +805,28 @@ func (m *Model) renderHistory() string {
|
|||||||
sessLines := m.sessColumnLines(sessCw, listH-1)
|
sessLines := m.sessColumnLines(sessCw, listH-1)
|
||||||
detailLines := m.detailColumnLines(detailCw, listH-1)
|
detailLines := m.detailColumnLines(detailCw, listH-1)
|
||||||
|
|
||||||
// 统一高度:补空行到相同行数
|
|
||||||
maxRows := min(listH-1, max(max(len(dirLines), len(sessLines)), len(detailLines)))
|
maxRows := min(listH-1, max(max(len(dirLines), len(sessLines)), len(detailLines)))
|
||||||
padLines := func(lines []string, w int) string {
|
|
||||||
for len(lines) < maxRows {
|
|
||||||
lines = append(lines, strings.Repeat(" ", w))
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
panelSty := lipgloss.NewStyle().
|
panelSty := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(style.BgPanel)
|
BorderForeground(style.BgPanel)
|
||||||
|
|
||||||
dirBox := panelSty.Width(dirW).Render(
|
dirBox := panelSty.Width(dirW).Render(
|
||||||
style.DetailTitle.Render("dirs") + "\n" + padLines(dirLines, dirCw))
|
style.DetailTitle.Render("dirs") + "\n" + padLinesTo(dirLines, dirCw, maxRows))
|
||||||
sessBox := panelSty.Width(sessW).Render(
|
sessBox := panelSty.Width(sessW).Render(
|
||||||
style.DetailTitle.Render("sessions") + "\n" + padLines(sessLines, sessCw))
|
style.DetailTitle.Render("sessions") + "\n" + padLinesTo(sessLines, sessCw, maxRows))
|
||||||
detailBox := panelSty.Width(detailW).Render(
|
detailBox := panelSty.Width(detailW).Render(
|
||||||
style.DetailTitle.Render("detail") + "\n" + padLines(detailLines, detailCw))
|
style.DetailTitle.Render("detail") + "\n" + padLinesTo(detailLines, detailCw, maxRows))
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, dirBox, " ", sessBox, " ", detailBox)
|
return lipgloss.JoinHorizontal(lipgloss.Top, dirBox, " ", sessBox, " ", detailBox)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) dirColumnLines(w, listH int) []string {
|
func (m *Model) dirColumnLines(w, listH int) []string {
|
||||||
var lines []string
|
var lines []string
|
||||||
total := len(m.history.Projects) + 1
|
total := len(m.history.Projects) + 3 // 全部 + 收藏 + 项目 + 回到工作空间
|
||||||
start, end := viewport(m.history.DirCursor, total, listH)
|
start, end := viewport(m.history.DirCursor, total, listH)
|
||||||
|
|
||||||
prefixW := 3 // " " + cur(1) + " "
|
prefixW := 3
|
||||||
|
|
||||||
buildDirLine := func(cur, name, cnt string, selected bool) string {
|
buildDirLine := func(cur, name, cnt string, selected bool) string {
|
||||||
cntW := stringWidth(cnt)
|
cntW := stringWidth(cnt)
|
||||||
@@ -717,6 +840,7 @@ func (m *Model) dirColumnLines(w, listH int) []string {
|
|||||||
return sty.Render(padRightByWidth(truncateByWidth(line, w), w))
|
return sty.Render(padRightByWidth(truncateByWidth(line, w), w))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 0: 全部
|
||||||
if start == 0 {
|
if start == 0 {
|
||||||
totalSess := 0
|
totalSess := 0
|
||||||
for _, pd := range m.history.Projects {
|
for _, pd := range m.history.Projects {
|
||||||
@@ -730,17 +854,36 @@ func (m *Model) dirColumnLines(w, listH int) []string {
|
|||||||
m.history.DirCursor == 0 && m.history.FocusPanel == 0))
|
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++ {
|
for i := 0; i < len(m.history.Projects); i++ {
|
||||||
if i+1 < start || i+1 >= end {
|
idx := i + 2
|
||||||
|
if idx < start || idx >= end {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pd := m.history.Projects[i]
|
pd := m.history.Projects[i]
|
||||||
cur := " "
|
cur := " "
|
||||||
if m.history.DirCursor == i+1 {
|
if m.history.DirCursor == idx {
|
||||||
cur = "▸"
|
cur = "▸"
|
||||||
}
|
}
|
||||||
lines = append(lines, buildDirLine(cur, pd.DirShort, fmt.Sprintf("(%d)", len(pd.Sessions)),
|
lines = append(lines, buildDirLine(cur, pd.DirShort, fmt.Sprintf("(%d)", len(pd.Sessions)),
|
||||||
m.history.DirCursor == i+1 && m.history.FocusPanel == 0))
|
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
|
return lines
|
||||||
}
|
}
|
||||||
@@ -751,10 +894,9 @@ func (m *Model) sessColumnLines(w, listH int) []string {
|
|||||||
return []string{style.SubtitleStyle.Render(fitWidth(" no sessions", w))}
|
return []string{style.SubtitleStyle.Render(fitWidth(" no sessions", w))}
|
||||||
}
|
}
|
||||||
|
|
||||||
prefixW := 15 // " " + cur(2) + timeStr(11) + " "
|
prefixW := 16
|
||||||
textW := max(8, w-prefixW)
|
textW := max(8, w-prefixW)
|
||||||
|
|
||||||
// 预渲染每个 session 为 1~N 行
|
|
||||||
type sessBlock struct {
|
type sessBlock struct {
|
||||||
lines []string
|
lines []string
|
||||||
}
|
}
|
||||||
@@ -768,6 +910,10 @@ func (m *Model) sessColumnLines(w, listH int) []string {
|
|||||||
cur = "▸ "
|
cur = "▸ "
|
||||||
}
|
}
|
||||||
timeStr := s.StartTime.Local().Format("01-02 15:04")
|
timeStr := s.StartTime.Local().Format("01-02 15:04")
|
||||||
|
favMark := " "
|
||||||
|
if m.history.Favorites[s.ID] {
|
||||||
|
favMark = "★"
|
||||||
|
}
|
||||||
title := s.DisplayTitle()
|
title := s.DisplayTitle()
|
||||||
|
|
||||||
wrapped := wrapByWidth(title, textW)
|
wrapped := wrapByWidth(title, textW)
|
||||||
@@ -784,9 +930,9 @@ func (m *Model) sessColumnLines(w, listH int) []string {
|
|||||||
for li, part := range wrapped {
|
for li, part := range wrapped {
|
||||||
var line string
|
var line string
|
||||||
if li == 0 {
|
if li == 0 {
|
||||||
line = fmt.Sprintf(" %s%s %s", cur, timeStr, part)
|
line = fmt.Sprintf(" %s%s%s %s", cur, timeStr, favMark, part)
|
||||||
} else {
|
} else {
|
||||||
line = strings.Repeat(" ", prefixW) + part
|
line = strings.Repeat(" ", prefixW+1) + part
|
||||||
}
|
}
|
||||||
blockLines = append(blockLines, sty.Render(padRightByWidth(truncateByWidth(line, w), w)))
|
blockLines = append(blockLines, sty.Render(padRightByWidth(truncateByWidth(line, w), w)))
|
||||||
}
|
}
|
||||||
@@ -798,7 +944,6 @@ func (m *Model) sessColumnLines(w, listH int) []string {
|
|||||||
blocks = append(blocks, sessBlock{lines: blockLines})
|
blocks = append(blocks, sessBlock{lines: blockLines})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按总行数做 viewport
|
|
||||||
startLine, endLine := viewport(curLineOffset, totalLines, listH)
|
startLine, endLine := viewport(curLineOffset, totalLines, listH)
|
||||||
|
|
||||||
var allLines []string
|
var allLines []string
|
||||||
@@ -820,30 +965,33 @@ func (m *Model) detailColumnLines(w, listH int) []string {
|
|||||||
sepSty := lipgloss.NewStyle().Foreground(style.BgPanel)
|
sepSty := lipgloss.NewStyle().Foreground(style.BgPanel)
|
||||||
var lines []string
|
var lines []string
|
||||||
|
|
||||||
// Title
|
favPrefix := " "
|
||||||
lines = append(lines, style.DetailTitle.Render(fitWidth(" "+s.DisplayTitle(), w)))
|
if m.history.Favorites[s.ID] {
|
||||||
|
favPrefix = "★ "
|
||||||
// Separator
|
}
|
||||||
|
lines = append(lines, style.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w)))
|
||||||
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
|
|
||||||
// Key-value rows
|
fav := "-"
|
||||||
|
if m.history.Favorites[s.ID] {
|
||||||
|
fav = "★"
|
||||||
|
}
|
||||||
kvRows := []struct{ k, v string }{
|
kvRows := []struct{ k, v string }{
|
||||||
{"dir", s.Cwd},
|
{"dir", s.Cwd},
|
||||||
{"time", fmt.Sprintf("%s ~ %s", s.StartTime.Local().Format("01-02 15:04"), s.EndTime.Local().Format("15:04"))},
|
{"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)},
|
{"msgs", fmt.Sprintf("%d", s.MsgCount)},
|
||||||
|
{"fav", fav},
|
||||||
}
|
}
|
||||||
for _, r := range kvRows {
|
for _, r := range kvRows {
|
||||||
line := fmt.Sprintf(" %-4s %s", r.k, r.v)
|
line := fmt.Sprintf(" %-4s %s", r.k, r.v)
|
||||||
lines = append(lines, style.NormStyle.Render(fitWidth(line, w)))
|
lines = append(lines, style.NormStyle.Render(fitWidth(line, w)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary
|
|
||||||
if summary := s.DisplaySummary(); summary != "" {
|
if summary := s.DisplaySummary(); summary != "" {
|
||||||
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
lines = append(lines, style.SessionSummaryStyle.Render(fitWidth(" "+summary, w)))
|
lines = append(lines, style.SessionSummaryStyle.Render(fitWidth(" "+summary, w)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Completed items
|
|
||||||
if len(s.Completed) > 0 {
|
if len(s.Completed) > 0 {
|
||||||
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
lines = append(lines, lipgloss.NewStyle().Foreground(style.Success).Bold(true).Render(fitWidth(" ✓ 已完成", w)))
|
lines = append(lines, lipgloss.NewStyle().Foreground(style.Success).Bold(true).Render(fitWidth(" ✓ 已完成", w)))
|
||||||
@@ -852,7 +1000,6 @@ func (m *Model) detailColumnLines(w, listH int) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending items
|
|
||||||
if len(s.Pending) > 0 {
|
if len(s.Pending) > 0 {
|
||||||
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
lines = append(lines, lipgloss.NewStyle().Foreground(style.Warning).Bold(true).Render(fitWidth(" ○ 待办", w)))
|
lines = append(lines, lipgloss.NewStyle().Foreground(style.Warning).Bold(true).Render(fitWidth(" ○ 待办", w)))
|
||||||
@@ -861,7 +1008,6 @@ func (m *Model) detailColumnLines(w, listH int) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hint
|
|
||||||
lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]resume", w)))
|
lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]resume", w)))
|
||||||
|
|
||||||
if len(lines) > listH {
|
if len(lines) > listH {
|
||||||
@@ -877,7 +1023,13 @@ func (m *Model) fmtHelp(key, desc string) string {
|
|||||||
lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc)
|
lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// viewport 返回 [start, end) 区间,保证 cursor 在可见范围内
|
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) {
|
func viewport(cursor, total, height int) (int, int) {
|
||||||
if total <= height {
|
if total <= height {
|
||||||
return 0, total
|
return 0, total
|
||||||
@@ -898,7 +1050,6 @@ func viewport(cursor, total, height int) (int, int) {
|
|||||||
return start, end
|
return start, end
|
||||||
}
|
}
|
||||||
|
|
||||||
// stringWidth 返回字符串的终端显示宽度
|
|
||||||
func stringWidth(s string) int {
|
func stringWidth(s string) int {
|
||||||
w := 0
|
w := 0
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
@@ -907,7 +1058,6 @@ func stringWidth(s string) int {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// padRightByWidth 补空格到指定视觉宽度
|
|
||||||
func padRightByWidth(s string, targetW int) string {
|
func padRightByWidth(s string, targetW int) string {
|
||||||
w := stringWidth(s)
|
w := stringWidth(s)
|
||||||
if w >= targetW {
|
if w >= targetW {
|
||||||
@@ -916,7 +1066,6 @@ func padRightByWidth(s string, targetW int) string {
|
|||||||
return s + strings.Repeat(" ", targetW-w)
|
return s + strings.Repeat(" ", targetW-w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wrapByWidth 按显示宽度换行,返回多行文本
|
|
||||||
func wrapByWidth(s string, maxW int) []string {
|
func wrapByWidth(s string, maxW int) []string {
|
||||||
if maxW <= 0 || s == "" {
|
if maxW <= 0 || s == "" {
|
||||||
return []string{s}
|
return []string{s}
|
||||||
@@ -930,12 +1079,28 @@ func wrapByWidth(s string, maxW int) []string {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
// fitWidth 截断并填充到精确视觉宽度
|
|
||||||
func fitWidth(s string, w int) string {
|
func fitWidth(s string, w int) string {
|
||||||
return padRightByWidth(truncateByWidth(s, w), w)
|
return padRightByWidth(truncateByWidth(s, w), w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncateByWidth 按显示宽度截断字符串,不切断多字节字符
|
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 {
|
func truncateByWidth(s string, maxW int) string {
|
||||||
w := 0
|
w := 0
|
||||||
for i := 0; i < len(s); {
|
for i := 0; i < len(s); {
|
||||||
@@ -950,7 +1115,6 @@ func truncateByWidth(s string, maxW int) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// runeWidth 返回单个 rune 的终端显示宽度 (CJK=2, 其他=1)
|
|
||||||
func runeWidth(r rune) int {
|
func runeWidth(r rune) int {
|
||||||
if r >= 0x1100 &&
|
if r >= 0x1100 &&
|
||||||
(r <= 0x115F || r == 0x2329 || r == 0x232A ||
|
(r <= 0x115F || r == 0x2329 || r == 0x232A ||
|
||||||
@@ -969,7 +1133,6 @@ func runeWidth(r rune) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func launchInWtWithTitle(dir, tabTitle, script string) {
|
func launchInWtWithTitle(dir, tabTitle, script string) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
encoded := encodePSCommand(script)
|
encoded := encodePSCommand(script)
|
||||||
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255))
|
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255))
|
||||||
args := []string{"-w", "0", "-d", dir, "--tabColor", color}
|
args := []string{"-w", "0", "-d", dir, "--tabColor", color}
|
||||||
@@ -981,26 +1144,24 @@ func launchInWtWithTitle(dir, tabTitle, script string) {
|
|||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
log.Printf("[u-tabs] launch fail: %v", err)
|
log.Printf("[u-tabs] launch fail: %v", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) LaunchWorkspace(ws Workspace) {
|
func (m *Model) LaunchWorkspace(ws Workspace) {
|
||||||
script := buildLaunchScript(ws)
|
script := buildLaunchScript(ws)
|
||||||
if runtime.GOOS != "windows" {
|
if !isWindows {
|
||||||
m.pendingCmd = "cd '" + ws.Dir + "' && " + script
|
m.pendingCmd = "cd '" + ws.Dir + "' && " + script
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchInWtWithTitle(ws.Dir, "", script)
|
launchInWtWithTitle(ws.Dir, "", script)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPendingCmd 返回退出后需执行的命令(Linux 用)
|
|
||||||
func (m *Model) GetPendingCmd() string {
|
func (m *Model) GetPendingCmd() string {
|
||||||
return m.pendingCmd
|
return m.pendingCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func resumeSession(s *Session) {
|
func resumeSession(s *Session) {
|
||||||
cwd := s.Cwd
|
cwd := s.Cwd
|
||||||
if runtime.GOOS == "windows" {
|
if isWindows {
|
||||||
cwd = strings.ReplaceAll(s.Cwd, "/", "\\")
|
cwd = strings.ReplaceAll(s.Cwd, "/", "\\")
|
||||||
}
|
}
|
||||||
title := s.CustomTitle
|
title := s.CustomTitle
|
||||||
@@ -1008,11 +1169,11 @@ func resumeSession(s *Session) {
|
|||||||
title = s.ID[:8]
|
title = s.ID[:8]
|
||||||
}
|
}
|
||||||
var script string
|
var script string
|
||||||
if runtime.GOOS == "windows" {
|
if isWindows {
|
||||||
script = fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "resume: %s"
|
script = fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "resume: %s"
|
||||||
Write-Host "=== Resuming: %s ===" -ForegroundColor Cyan
|
Write-Host "=== Resuming: %s ===" -ForegroundColor Cyan
|
||||||
cd "%s"
|
cd "%s"
|
||||||
claude -r %s --permission-mode bypassPermissions`, title, title, cwd, s.ID)
|
claude -r %s --permission-mode bypassPermissions`, title, title, cwd, s.ID)
|
||||||
} else {
|
} else {
|
||||||
script = fmt.Sprintf(`printf '\033]0;resume: %s\007' && echo "=== Resuming: %s ===" && cd "%s" && claude -r %s`, title, title, cwd, s.ID)
|
script = fmt.Sprintf(`printf '\033]0;resume: %s\007' && echo "=== Resuming: %s ===" && cd "%s" && claude -r %s`, title, title, cwd, s.ID)
|
||||||
}
|
}
|
||||||
@@ -1020,12 +1181,12 @@ claude -r %s --permission-mode bypassPermissions`, title, title, cwd, s.ID)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildLaunchScript(ws Workspace) string {
|
func buildLaunchScript(ws Workspace) string {
|
||||||
if runtime.GOOS == "windows" {
|
if isWindows {
|
||||||
return fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s"
|
return fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s"
|
||||||
Write-Host "=== %s ===" -ForegroundColor Cyan
|
Write-Host "=== %s ===" -ForegroundColor Cyan
|
||||||
Write-Host "Prompt: %s" -ForegroundColor Yellow
|
Write-Host "Prompt: %s" -ForegroundColor Yellow
|
||||||
cd "%s"
|
cd "%s"
|
||||||
claude --name "%s" --permission-mode bypassPermissions`,
|
claude --name "%s" --permission-mode bypassPermissions`,
|
||||||
ws.Title, ws.Title, ws.Prompt, ws.Dir, ws.Title)
|
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"`,
|
return fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ===" && echo "Prompt: %s" && cd "%s" && claude --name "%s"`,
|
||||||
@@ -1046,4 +1207,3 @@ func randRange(min, max int) int {
|
|||||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min)))
|
n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min)))
|
||||||
return min + int(n.Int64())
|
return min + int(n.Int64())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ type HistoryState struct {
|
|||||||
FocusPanel int // 0=左栏 1=中栏
|
FocusPanel int // 0=左栏 1=中栏
|
||||||
Loaded bool
|
Loaded bool
|
||||||
Scanning bool
|
Scanning bool
|
||||||
|
Favorites map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsHistoryTab 判断当前是否在 HISTORY Tab
|
// IsHistoryTab 判断当前是否在 HISTORY Tab
|
||||||
@@ -203,7 +204,6 @@ func scanSessionFile(path string, sessionID string) *Session {
|
|||||||
scanner := bufio.NewScanner(f)
|
scanner := bufio.NewScanner(f)
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
|
||||||
lineCount := 0
|
lineCount := 0
|
||||||
firstMsgFound := false
|
firstMsgFound := false
|
||||||
|
|
||||||
@@ -551,3 +551,76 @@ func saveSummaryToCache(sessionID, summary string, completed, pending []string)
|
|||||||
entry.Pending = pending
|
entry.Pending = pending
|
||||||
saveCache(home, cache)
|
saveCache(home, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 收藏 ---
|
||||||
|
|
||||||
|
func favoritesPath(home string) string {
|
||||||
|
return filepath.Join(home, ".u-tabs", "favorites.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFavorites() map[string]bool {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return make(map[string]bool)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(favoritesPath(home))
|
||||||
|
if err != nil {
|
||||||
|
return make(map[string]bool)
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
if json.Unmarshal(data, &ids) != nil {
|
||||||
|
return make(map[string]bool)
|
||||||
|
}
|
||||||
|
m := make(map[string]bool, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
m[id] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveFavorites(favs map[string]bool) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(favs))
|
||||||
|
for id := range favs {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(ids)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := filepath.Join(home, ".u-tabs")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
log.Printf("[u-tabs] favorites mkdir fail: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(favoritesPath(home), data, 0o644); err != nil {
|
||||||
|
log.Printf("[u-tabs] favorites write fail: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countFavorites(projects []*ProjectDir, favs map[string]bool) int {
|
||||||
|
n := 0
|
||||||
|
for _, pd := range projects {
|
||||||
|
for _, s := range pd.Sessions {
|
||||||
|
if favs[s.ID] {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectFavorites(projects []*ProjectDir, favs map[string]bool) []*Session {
|
||||||
|
var out []*Session
|
||||||
|
for _, pd := range projects {
|
||||||
|
for _, s := range pd.Sessions {
|
||||||
|
if favs[s.ID] {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user