新增: 历史对话收藏功能,重构 Tab 导航与平台检查

This commit is contained in:
2026-05-22 10:46:57 +08:00
parent 5e6708d049
commit e125ac6088
2 changed files with 476 additions and 243 deletions

View File

@@ -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,62 +285,78 @@ 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"
@@ -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
} }
groupHeader := gs.Render(fmt.Sprintf(" %s · %d ", g.Label, len(svcs))) func (m *Model) wsDetailLines(svcs []Workspace, w, listH int) []string {
listBox := lipgloss.NewStyle(). if m.cursor >= len(svcs) {
Border(lipgloss.NormalBorder()). return []string{lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" ← select to view", w))}
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}
@@ -982,25 +1145,23 @@ func launchInWtWithTitle(dir, tabTitle, script string) {
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,7 +1169,7 @@ 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"
@@ -1020,7 +1181,7 @@ 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
@@ -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())
} }

View File

@@ -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
}