新增: HISTORY 历史会话功能
三栏布局(目录/会话/详情), 扫描 ~/.claude/projects JSONL, 支持 resume 会话, 缓存增量更新, 代码审查问题修复
This commit is contained in:
482
internal/app.go
482
internal/app.go
@@ -26,6 +26,7 @@ type Model struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
launched string
|
launched string
|
||||||
|
history HistoryState
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel() *Model {
|
func NewModel() *Model {
|
||||||
@@ -39,28 +40,81 @@ func (m *Model) Init() tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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:
|
||||||
s := msg.String()
|
return m.handleKey(msg.String())
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
case ScanCompleteMsg:
|
||||||
|
m.history.Projects = msg.Projects
|
||||||
|
m.history.Loaded = true
|
||||||
|
m.history.Scanning = false
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 按键分发 ---
|
||||||
|
|
||||||
|
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
|
||||||
case "tab", "right", "l":
|
}
|
||||||
m.activeGroup = (m.activeGroup + 1) % len(Groups)
|
|
||||||
m.cursor = 0
|
// 数字键跳转 Tab
|
||||||
m.inputBuf = ""
|
if len(s) == 1 && s[0] >= '1' && s[0] <= '9' {
|
||||||
case "shift+tab", "left", "h":
|
|
||||||
m.activeGroup = (m.activeGroup - 1 + len(Groups)) % len(Groups)
|
|
||||||
m.cursor = 0
|
|
||||||
m.inputBuf = ""
|
|
||||||
case "1", "2", "3", "4":
|
|
||||||
idx, _ := strconv.Atoi(s)
|
idx, _ := strconv.Atoi(s)
|
||||||
if idx <= len(Groups) {
|
if idx <= m.totalTabs() {
|
||||||
m.activeGroup = idx - 1
|
m.activeGroup = idx - 1
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
m.inputBuf = ""
|
m.inputBuf = ""
|
||||||
|
return m, m.onTabSwitch()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HISTORY Tab: tab/left/right 用于面板切换
|
||||||
|
if m.IsHistoryTab() {
|
||||||
|
return m.handleHistoryKey(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace Tab 按键
|
||||||
|
switch s {
|
||||||
|
case "tab", "right", "l":
|
||||||
|
m.activeGroup = (m.activeGroup + 1) % m.totalTabs()
|
||||||
|
m.cursor = 0
|
||||||
|
m.inputBuf = ""
|
||||||
|
return m, m.onTabSwitch()
|
||||||
|
case "shift+tab", "left", "h":
|
||||||
|
m.activeGroup = (m.activeGroup - 1 + m.totalTabs()) % m.totalTabs()
|
||||||
|
m.cursor = 0
|
||||||
|
m.inputBuf = ""
|
||||||
|
return m, m.onTabSwitch()
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.handleWorkspaceKey(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) onTabSwitch() tea.Cmd {
|
||||||
|
if m.IsHistoryTab() && !m.history.Loaded && !m.history.Scanning {
|
||||||
|
m.history.Scanning = true
|
||||||
|
return ScanSessionsCmd()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Workspace Tab 按键 ---
|
||||||
|
|
||||||
|
func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) {
|
||||||
|
switch s {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
m.moveCursor(-1)
|
m.moveCursor(-1)
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
@@ -77,13 +131,99 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.inputBuf += s
|
m.inputBuf += s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case tea.WindowSizeMsg:
|
return m, nil
|
||||||
m.width = msg.Width
|
}
|
||||||
m.height = msg.Height
|
|
||||||
|
// --- HISTORY Tab 按键 ---
|
||||||
|
|
||||||
|
func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
||||||
|
switch s {
|
||||||
|
case "up", "k":
|
||||||
|
if m.history.FocusPanel == 0 {
|
||||||
|
m.moveHistoryDir(-1)
|
||||||
|
} else {
|
||||||
|
m.moveHistorySess(-1)
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if m.history.FocusPanel == 0 {
|
||||||
|
m.moveHistoryDir(1)
|
||||||
|
} else {
|
||||||
|
m.moveHistorySess(1)
|
||||||
|
}
|
||||||
|
case "tab", "right", "l":
|
||||||
|
if m.history.FocusPanel < 1 {
|
||||||
|
m.history.FocusPanel = 1
|
||||||
|
}
|
||||||
|
case "shift+tab", "left", "h":
|
||||||
|
if m.history.FocusPanel > 0 {
|
||||||
|
m.history.FocusPanel = 0
|
||||||
|
}
|
||||||
|
case "enter":
|
||||||
|
if m.history.FocusPanel == 0 {
|
||||||
|
// 在左栏按 enter 切到中栏
|
||||||
|
m.history.FocusPanel = 1
|
||||||
|
} else {
|
||||||
|
return m.resumeSelected()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) moveHistoryDir(dir int) {
|
||||||
|
if len(m.history.Projects) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.history.DirCursor += dir
|
||||||
|
if m.history.DirCursor < 0 {
|
||||||
|
m.history.DirCursor = len(m.history.Projects) - 1
|
||||||
|
}
|
||||||
|
if m.history.DirCursor >= len(m.history.Projects) {
|
||||||
|
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 < len(m.history.Projects) {
|
||||||
|
return m.history.Projects[m.history.DirCursor].Sessions
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) currentSession() *Session {
|
||||||
|
sessions := m.currentSessions()
|
||||||
|
if len(sessions) == 0 || m.history.SessCursor >= len(sessions) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sessions[m.history.SessCursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) resumeSelected() (*Model, tea.Cmd) {
|
||||||
|
s := m.currentSession()
|
||||||
|
if s == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
go resumeSession(s)
|
||||||
|
m.launched = "resume: " + s.CustomTitle
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Workspace 光标 ---
|
||||||
|
|
||||||
func (m *Model) moveCursor(dir int) {
|
func (m *Model) moveCursor(dir int) {
|
||||||
svcs := WorkspacesByGroup(Groups[m.activeGroup].Label)
|
svcs := WorkspacesByGroup(Groups[m.activeGroup].Label)
|
||||||
if len(svcs) == 0 {
|
if len(svcs) == 0 {
|
||||||
@@ -146,6 +286,7 @@ func (m *Model) launchByInput() (*Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- View ---
|
||||||
|
|
||||||
func (m *Model) View() tea.View {
|
func (m *Model) View() tea.View {
|
||||||
v := tea.NewView(m.render())
|
v := tea.NewView(m.render())
|
||||||
@@ -162,13 +303,11 @@ func (m *Model) render() string {
|
|||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
// layout widths
|
|
||||||
listW := max(42, min(65, m.width*55/100))
|
|
||||||
detailW := max(30, m.width-listW-3)
|
|
||||||
|
|
||||||
// ── header: title + tabs ──
|
// ── header: title + tabs ──
|
||||||
b.WriteString(style.TitleStyle.Render(" u-tabs "))
|
b.WriteString(style.TitleStyle.Render(" u-tabs "))
|
||||||
sep := style.TabSep.Render(" | ")
|
sep := style.TabSep.Render(" | ")
|
||||||
|
|
||||||
|
// Workspace Tabs
|
||||||
for i, g := range Groups {
|
for i, g := range Groups {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
b.WriteString(sep)
|
b.WriteString(sep)
|
||||||
@@ -187,19 +326,83 @@ func (m *Model) render() string {
|
|||||||
b.WriteString(style.TabInactiveStyle.Render(label))
|
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()).
|
||||||
|
Padding(0, 1).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")
|
||||||
|
|
||||||
|
// ── 内容区 ──
|
||||||
|
if m.IsHistoryTab() {
|
||||||
|
b.WriteString(m.renderHistory())
|
||||||
|
} else {
|
||||||
|
b.WriteString(m.renderWorkspace())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── footer ──
|
||||||
|
b.WriteString("\n")
|
||||||
|
if m.inputBuf != "" {
|
||||||
|
b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ▶ num:%s [Enter]go [Esc]cancel", m.inputBuf)))
|
||||||
|
}
|
||||||
|
if m.launched != "" {
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched)))
|
||||||
|
m.launched = ""
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
helpParts := m.renderHelp()
|
||||||
|
b.WriteString(" " + strings.Join(helpParts, " "))
|
||||||
|
|
||||||
|
if hint := ConfigHint(); hint != "" {
|
||||||
|
b.WriteString("\n" + hint)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderHelp() []string {
|
||||||
|
if m.IsHistoryTab() {
|
||||||
|
return []string{
|
||||||
|
m.fmtHelp("j/k", "sel"),
|
||||||
|
m.fmtHelp("Tab", "→panel"),
|
||||||
|
m.fmtHelp("Enter", "resume"),
|
||||||
|
m.fmtHelp("1-"+fmt.Sprintf("%d", m.totalTabs()), "tab"),
|
||||||
|
m.fmtHelp("q", "quit"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{
|
||||||
|
m.fmtHelp("j/k", "sel"),
|
||||||
|
m.fmtHelp("Enter", "run"),
|
||||||
|
m.fmtHelp("Space", "multi"),
|
||||||
|
m.fmtHelp("Tab", "group"),
|
||||||
|
m.fmtHelp("q", "quit"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Workspace 渲染 ---
|
||||||
|
|
||||||
|
func (m *Model) renderWorkspace() string {
|
||||||
|
listW := max(42, min(65, m.width*55/100))
|
||||||
|
detailW := max(30, m.width-listW-3)
|
||||||
|
|
||||||
g := Groups[m.activeGroup]
|
g := Groups[m.activeGroup]
|
||||||
gs, _ := style.GroupStyles[g.Label]
|
gs, _ := style.GroupStyles[g.Label]
|
||||||
svcs := WorkspacesByGroup(g.Label)
|
svcs := WorkspacesByGroup(g.Label)
|
||||||
if len(svcs) == 0 {
|
if len(svcs) == 0 {
|
||||||
b.WriteString(style.SubtitleStyle.Render(" empty"))
|
return style.SubtitleStyle.Render(" empty")
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ left: list ═══
|
// 左栏: 列表
|
||||||
var left strings.Builder
|
var left strings.Builder
|
||||||
innerW := listW - 4
|
innerW := listW - 4
|
||||||
for i, ws := range svcs {
|
for i, ws := range svcs {
|
||||||
@@ -209,19 +412,26 @@ func (m *Model) render() string {
|
|||||||
}
|
}
|
||||||
mark := " "
|
mark := " "
|
||||||
if m.selected[ws.Index] {
|
if m.selected[ws.Index] {
|
||||||
mark = style.MarkStyle.Render("✓")
|
mark = "✓"
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == m.cursor {
|
||||||
|
// 选中行:纯文本,让 SelStyle 统一着色
|
||||||
|
prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " "
|
||||||
|
remainW := max(10, innerW-len(prefix))
|
||||||
|
text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW)
|
||||||
|
left.WriteString(style.SelStyle.Width(innerW).Render(prefix + text))
|
||||||
|
} else {
|
||||||
|
// 非选中行:子样式着色
|
||||||
|
markStr := " "
|
||||||
|
if m.selected[ws.Index] {
|
||||||
|
markStr = style.MarkStyle.Render("✓")
|
||||||
}
|
}
|
||||||
num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N))
|
num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N))
|
||||||
|
prefix := cur + " " + markStr + " " + num + " "
|
||||||
prefix := cur + " " + mark + " " + num + " "
|
|
||||||
remainW := max(10, innerW-lipgloss.Width(prefix))
|
remainW := max(10, innerW-lipgloss.Width(prefix))
|
||||||
text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW)
|
text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW)
|
||||||
|
left.WriteString(style.NormStyle.Render(prefix + text))
|
||||||
line := prefix + text
|
|
||||||
if i == m.cursor {
|
|
||||||
left.WriteString(style.SelStyle.Width(innerW).Render(line))
|
|
||||||
} else {
|
|
||||||
left.WriteString(style.NormStyle.Render(line))
|
|
||||||
}
|
}
|
||||||
left.WriteString("\n")
|
left.WriteString("\n")
|
||||||
}
|
}
|
||||||
@@ -234,7 +444,7 @@ func (m *Model) render() string {
|
|||||||
Padding(0, 1).
|
Padding(0, 1).
|
||||||
Render(groupHeader + "\n" + left.String())
|
Render(groupHeader + "\n" + left.String())
|
||||||
|
|
||||||
// ═══ right: detail ═══
|
// 右栏: 详情
|
||||||
var right strings.Builder
|
var right strings.Builder
|
||||||
if m.cursor < len(svcs) {
|
if m.cursor < len(svcs) {
|
||||||
ws := svcs[m.cursor]
|
ws := svcs[m.cursor]
|
||||||
@@ -279,34 +489,165 @@ func (m *Model) render() string {
|
|||||||
Padding(0, 1).
|
Padding(0, 1).
|
||||||
Render(right.String())
|
Render(right.String())
|
||||||
|
|
||||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, listBox, " ", detailBox))
|
return lipgloss.JoinHorizontal(lipgloss.Left, listBox, " ", detailBox)
|
||||||
|
|
||||||
// ── footer ──
|
|
||||||
b.WriteString("\n")
|
|
||||||
if m.inputBuf != "" {
|
|
||||||
b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ▶ num:%s [Enter]go [Esc]cancel", m.inputBuf)))
|
|
||||||
}
|
|
||||||
if m.launched != "" {
|
|
||||||
b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched)))
|
|
||||||
m.launched = ""
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
helpParts := []string{
|
|
||||||
m.fmtHelp("j/k", "sel"),
|
|
||||||
m.fmtHelp("Enter", "run"),
|
|
||||||
m.fmtHelp("Space", "multi"),
|
|
||||||
m.fmtHelp("Tab", "group"),
|
|
||||||
m.fmtHelp("q", "quit"),
|
|
||||||
}
|
|
||||||
b.WriteString(" " + strings.Join(helpParts, " "))
|
|
||||||
|
|
||||||
if hint := ConfigHint(); hint != "" {
|
|
||||||
b.WriteString("\n" + hint)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- HISTORY 三栏渲染 ---
|
||||||
|
|
||||||
|
func (m *Model) renderHistory() string {
|
||||||
|
if m.history.Scanning {
|
||||||
|
return style.ScanningStyle.Render(" scanning sessions...")
|
||||||
|
}
|
||||||
|
if !m.history.Loaded {
|
||||||
|
return style.ScanningStyle.Render(" press Tab to load sessions...")
|
||||||
|
}
|
||||||
|
if len(m.history.Projects) == 0 {
|
||||||
|
return style.SubtitleStyle.Render(" no sessions found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 三栏宽度分配
|
||||||
|
avail := m.width - 4 // borders + gaps
|
||||||
|
dirW := max(20, avail*20/100)
|
||||||
|
sessW := max(30, avail*40/100)
|
||||||
|
detailW := max(20, avail-dirW-sessW)
|
||||||
|
|
||||||
|
// 左栏: 目录列表
|
||||||
|
dirBox := m.renderDirPanel(dirW)
|
||||||
|
// 中栏: 会话列表
|
||||||
|
sessBox := m.renderSessPanel(sessW)
|
||||||
|
// 右栏: 详情
|
||||||
|
detailBox := m.renderHistoryDetail(detailW)
|
||||||
|
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Left, dirBox, " ", sessBox, " ", detailBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderDirPanel(w int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
innerW := w - 4
|
||||||
|
|
||||||
|
b.WriteString(style.DetailTitle.Render(" dirs "))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
for i, pd := range m.history.Projects {
|
||||||
|
cur := " "
|
||||||
|
if i == m.history.DirCursor {
|
||||||
|
cur = "▸"
|
||||||
|
}
|
||||||
|
name := truncateByWidth(pd.DirShort, max(8, innerW-8))
|
||||||
|
|
||||||
|
if i == m.history.DirCursor && m.history.FocusPanel == 0 {
|
||||||
|
line := fmt.Sprintf("%s %s (%d)", cur, name, len(pd.Sessions))
|
||||||
|
b.WriteString(style.SelStyle.Width(innerW).Render(line))
|
||||||
|
} else {
|
||||||
|
cnt := style.DirCountStyle.Render(fmt.Sprintf("(%d)", len(pd.Sessions)))
|
||||||
|
line := fmt.Sprintf("%s %s %s", cur, name, cnt)
|
||||||
|
b.WriteString(style.NormStyle.Render(line))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(style.BgPanel).
|
||||||
|
Width(w).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderSessPanel(w int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
innerW := w - 4
|
||||||
|
|
||||||
|
b.WriteString(style.DetailTitle.Render(" sessions "))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
sessions := m.currentSessions()
|
||||||
|
if len(sessions) == 0 {
|
||||||
|
b.WriteString(style.SubtitleStyle.Render(" no sessions"))
|
||||||
|
} else {
|
||||||
|
for i, s := range sessions {
|
||||||
|
cur := " "
|
||||||
|
if i == m.history.SessCursor {
|
||||||
|
cur = "▸"
|
||||||
|
}
|
||||||
|
timeStr := s.StartTime.Format("01-02 15:04")
|
||||||
|
title := s.DisplayTitle()
|
||||||
|
remainW := max(10, innerW-10)
|
||||||
|
text := truncateByWidth(title, remainW)
|
||||||
|
|
||||||
|
if i == m.history.SessCursor && m.history.FocusPanel == 1 {
|
||||||
|
line := fmt.Sprintf("%s %s %s", cur, timeStr, text)
|
||||||
|
b.WriteString(style.SelStyle.Width(innerW).Render(line))
|
||||||
|
} else {
|
||||||
|
line := fmt.Sprintf("%s %s %s", cur, style.SessionTimeStyle.Render(timeStr), text)
|
||||||
|
b.WriteString(style.NormStyle.Render(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(style.BgPanel).
|
||||||
|
Width(w).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderHistoryDetail(w int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
s := m.currentSession()
|
||||||
|
if s == nil {
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" ← select to view"))
|
||||||
|
} else {
|
||||||
|
b.WriteString(style.DetailTitle.Render(" detail "))
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", w-12)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
rows := []struct {
|
||||||
|
key string
|
||||||
|
val string
|
||||||
|
sty lipgloss.Style
|
||||||
|
}{
|
||||||
|
{"title", s.CustomTitle, style.ValStyle},
|
||||||
|
{"dir", s.Cwd, style.ValStyle},
|
||||||
|
{"time", fmt.Sprintf("%s ~ %s", s.StartTime.Format("01-02 15:04"), s.EndTime.Format("15:04")), style.NumStyle},
|
||||||
|
{"msgs", fmt.Sprintf("%d", s.MsgCount), style.SessionMsgCntStyle},
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(style.KeyStyle.Render(r.key))
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(r.sty.Render(r.val))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 摘要
|
||||||
|
summary := s.DisplaySummary()
|
||||||
|
if summary != "" {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(" " + strings.Repeat("─", w-6)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(style.SessionSummaryStyle.Render(" " + truncateByWidth(summary, w-6)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" [Enter]resume"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(style.BgPanel).
|
||||||
|
Width(w).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 工具 ---
|
||||||
|
|
||||||
func (m *Model) fmtHelp(key, desc string) string {
|
func (m *Model) fmtHelp(key, desc string) string {
|
||||||
return lipgloss.NewStyle().Foreground(style.Accent).Render(key) +
|
return lipgloss.NewStyle().Foreground(style.Accent).Render(key) +
|
||||||
lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc)
|
lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc)
|
||||||
@@ -346,22 +687,39 @@ func runeWidth(r rune) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- launch ---
|
// --- 启动 ---
|
||||||
|
|
||||||
func launchWorkspace(ws Workspace) {
|
func launchInWt(dir, script string) {
|
||||||
script := buildLaunchScript(ws)
|
|
||||||
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))
|
||||||
cmd := exec.Command("wt.exe", "-w", "0",
|
cmd := exec.Command("wt.exe", "-w", "0",
|
||||||
"-d", ws.Dir,
|
"-d", dir,
|
||||||
"--tabColor", color,
|
"--tabColor", color,
|
||||||
"pwsh", "-NoExit", "-EncodedCommand", encoded,
|
"pwsh", "-NoExit", "-EncodedCommand", encoded,
|
||||||
)
|
)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
log.Printf("[u-tabs] launch fail %s(%d): %v", ws.Title, ws.N, err)
|
log.Printf("[u-tabs] launch fail: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func launchWorkspace(ws Workspace) {
|
||||||
|
script := buildLaunchScript(ws)
|
||||||
|
launchInWt(ws.Dir, script)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeSession(s *Session) {
|
||||||
|
cwd := strings.ReplaceAll(s.Cwd, "/", "\\")
|
||||||
|
title := s.CustomTitle
|
||||||
|
if title == "" {
|
||||||
|
title = s.ID[:8]
|
||||||
|
}
|
||||||
|
script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "resume: %s"
|
||||||
|
Write-Host "=== Resuming: %s ===" -ForegroundColor Cyan
|
||||||
|
cd "%s"
|
||||||
|
claude -r %s`, title, title, cwd, s.ID)
|
||||||
|
launchInWt(cwd, script)
|
||||||
|
}
|
||||||
|
|
||||||
func buildLaunchScript(ws Workspace) string {
|
func buildLaunchScript(ws Workspace) string {
|
||||||
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
|
||||||
|
|||||||
406
internal/history.go
Normal file
406
internal/history.go
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/bubbletea/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session 单个会话的元数据
|
||||||
|
type Session struct {
|
||||||
|
ID string // UUID (文件名)
|
||||||
|
CustomTitle string // customTitle 字段
|
||||||
|
Cwd string // 实际工作目录
|
||||||
|
StartTime time.Time // 首条 timestamp
|
||||||
|
EndTime time.Time // 末条 timestamp
|
||||||
|
MsgCount int // user + assistant 消息数
|
||||||
|
FirstMsg string // 首条用户消息 (截断)
|
||||||
|
AwaySummary string // away_summary 系统摘要
|
||||||
|
AISummary string // AI 生成的摘要 (缓存)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectDir 按目录分组的会话集合
|
||||||
|
type ProjectDir struct {
|
||||||
|
Dir string // 完整路径
|
||||||
|
DirShort string // 显示名 (路径最后一段)
|
||||||
|
Sessions []*Session // 按时间倒序
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayTitle 返回会话显示标题
|
||||||
|
func (s *Session) DisplayTitle() string {
|
||||||
|
if s.AISummary != "" {
|
||||||
|
return s.AISummary
|
||||||
|
}
|
||||||
|
if s.FirstMsg != "" {
|
||||||
|
return s.FirstMsg
|
||||||
|
}
|
||||||
|
if s.CustomTitle != "" {
|
||||||
|
return s.CustomTitle
|
||||||
|
}
|
||||||
|
return s.ID[:8]
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplaySummary 返回会话摘要
|
||||||
|
func (s *Session) DisplaySummary() string {
|
||||||
|
if s.AwaySummary != "" {
|
||||||
|
return s.AwaySummary
|
||||||
|
}
|
||||||
|
return s.FirstMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryState HISTORY Tab 视图状态
|
||||||
|
type HistoryState struct {
|
||||||
|
Projects []*ProjectDir
|
||||||
|
DirCursor int // 左栏光标
|
||||||
|
SessCursor int // 中栏光标
|
||||||
|
FocusPanel int // 0=左栏 1=中栏
|
||||||
|
Loaded bool
|
||||||
|
Scanning bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHistoryTab 判断当前是否在 HISTORY Tab
|
||||||
|
func (m *Model) IsHistoryTab() bool {
|
||||||
|
return m.activeGroup == len(Groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 扫描 ---
|
||||||
|
|
||||||
|
type ScanCompleteMsg struct {
|
||||||
|
Projects []*ProjectDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanSessionsCmd() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return ScanCompleteMsg{Projects: scanAllProjects()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAllProjects() []*ProjectDir {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
projectsDir := filepath.Join(home, ".claude", "projects")
|
||||||
|
entries, err := os.ReadDir(projectsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := loadCache(home)
|
||||||
|
dirMap := make(map[string]*ProjectDir)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
projectPath := filepath.Join(projectsDir, entry.Name())
|
||||||
|
files, err := os.ReadDir(projectPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsDir() || !strings.HasSuffix(f.Name(), ".jsonl") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sessionID := strings.TrimSuffix(f.Name(), ".jsonl")
|
||||||
|
filePath := filepath.Join(projectPath, f.Name())
|
||||||
|
info, err := f.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(info.ModTime()) {
|
||||||
|
addSessionToDir(dirMap, cached.ToSession(sessionID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
session := scanSessionFile(filePath, sessionID)
|
||||||
|
if session == nil || session.Cwd == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addSessionToDir(dirMap, session)
|
||||||
|
cache[sessionID] = cacheEntryFrom(session, info.ModTime())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCache(home, cache)
|
||||||
|
|
||||||
|
result := make([]*ProjectDir, 0, len(dirMap))
|
||||||
|
for _, pd := range dirMap {
|
||||||
|
sort.Slice(pd.Sessions, func(i, j int) bool {
|
||||||
|
return pd.Sessions[i].StartTime.After(pd.Sessions[j].StartTime)
|
||||||
|
})
|
||||||
|
result = append(result, pd)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return result[i].Dir < result[j].Dir
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) {
|
||||||
|
cwd := strings.ReplaceAll(s.Cwd, "\\", "/")
|
||||||
|
pd, ok := dirMap[cwd]
|
||||||
|
if !ok {
|
||||||
|
pd = &ProjectDir{Dir: cwd, DirShort: filepath.Base(cwd)}
|
||||||
|
dirMap[cwd] = pd
|
||||||
|
}
|
||||||
|
pd.Sessions = append(pd.Sessions, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSONL 扫描 ---
|
||||||
|
|
||||||
|
const scanLineLimit = 500
|
||||||
|
|
||||||
|
func scanSessionFile(path string, sessionID string) *Session {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
s := &Session{ID: sessionID}
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
lineCount := 0
|
||||||
|
firstMsgFound := false
|
||||||
|
scannedLines := 0 // 有效行(含 type 的)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineCount++
|
||||||
|
line := scanner.Bytes()
|
||||||
|
|
||||||
|
if !hasType(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scannedLines++
|
||||||
|
|
||||||
|
// cwd — 统一为正斜杠
|
||||||
|
if s.Cwd == "" {
|
||||||
|
extractField(line, `"cwd":"`, &s.Cwd)
|
||||||
|
s.Cwd = strings.ReplaceAll(s.Cwd, "\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// timestamp
|
||||||
|
var ts string
|
||||||
|
if extractField(line, `"timestamp":"`, &ts) {
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
|
||||||
|
if s.StartTime.IsZero() || t.Before(s.StartTime) {
|
||||||
|
s.StartTime = t
|
||||||
|
}
|
||||||
|
if t.After(s.EndTime) {
|
||||||
|
s.EndTime = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case matchType(line, "custom-title"):
|
||||||
|
extractField(line, `"customTitle":"`, &s.CustomTitle)
|
||||||
|
case matchSubtype(line, "away_summary"):
|
||||||
|
extractField(line, `"content":"`, &s.AwaySummary)
|
||||||
|
case matchType(line, "user"):
|
||||||
|
if bytes.Contains(line, []byte(`"tool_use_id"`)) || bytes.Contains(line, []byte(`"isMeta":true`)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.MsgCount++
|
||||||
|
if !firstMsgFound {
|
||||||
|
if msg := extractUserMsg(line); msg != "" {
|
||||||
|
s.FirstMsg = truncStr(msg, 80)
|
||||||
|
firstMsgFound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case matchType(line, "assistant"):
|
||||||
|
s.MsgCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if lineCount > scanLineLimit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 scannedLines/lineCount 比例估算总消息数
|
||||||
|
if lineCount > scanLineLimit && scannedLines > 0 {
|
||||||
|
ratio := float64(lineCount) / float64(scanLineLimit)
|
||||||
|
s.MsgCount = int(float64(s.MsgCount) * ratio)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchType 检查 JSON 行是否包含指定 type 值
|
||||||
|
func matchType(line []byte, typeName string) bool {
|
||||||
|
compact := `"type":"` + typeName + `"`
|
||||||
|
spaced := `"type": "` + typeName + `"`
|
||||||
|
return bytes.Contains(line, []byte(compact)) || bytes.Contains(line, []byte(spaced))
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchSubtype 检查 JSON 行是否包含指定 subtype 值
|
||||||
|
func matchSubtype(line []byte, subName string) bool {
|
||||||
|
compact := `"subtype":"` + subName + `"`
|
||||||
|
spaced := `"subtype": "` + subName + `"`
|
||||||
|
return bytes.Contains(line, []byte(compact)) || bytes.Contains(line, []byte(spaced))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasType 快速过滤:行是否包含 type 字段
|
||||||
|
func hasType(line []byte) bool {
|
||||||
|
return bytes.Contains(line, []byte(`"type":"`)) || bytes.Contains(line, []byte(`"type": "`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractField 从 JSON 行中提取 "key":"value" 的值
|
||||||
|
func extractField(line []byte, prefix string, out *string) bool {
|
||||||
|
idx := bytes.Index(line, []byte(prefix))
|
||||||
|
if idx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// prefix 长度 = len("key\":"),data 从引号开始
|
||||||
|
return extractJSONString(line[idx+len(prefix)-1:], out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractUserMsg(line []byte) string {
|
||||||
|
// string 格式: "content":"xxx"
|
||||||
|
if idx := bytes.Index(line, []byte(`"content":"`)); idx >= 0 {
|
||||||
|
var content string
|
||||||
|
extractJSONString(line[idx+10:], &content)
|
||||||
|
if content != "" && !strings.HasPrefix(content, "<command") &&
|
||||||
|
!strings.HasPrefix(content, "<system-reminder") &&
|
||||||
|
!strings.HasPrefix(content, "[Request interrupted") {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// array 格式: [{"type":"text","text":"xxx"}]
|
||||||
|
if idx := bytes.Index(line, []byte(`"type":"text"`)); idx >= 0 {
|
||||||
|
searchStart := max(0, idx-200)
|
||||||
|
region := line[searchStart:]
|
||||||
|
if tidx := bytes.Index(region, []byte(`"text":"`)); tidx >= 0 {
|
||||||
|
var text string
|
||||||
|
if extractJSONString(region[tidx+6:], &text) && text != "" &&
|
||||||
|
!strings.HasPrefix(text, "Base directory") {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractJSONString 从 " 开头的 JSON 数据中提取字符串值
|
||||||
|
func extractJSONString(data []byte, out *string) bool {
|
||||||
|
if len(data) == 0 || data[0] != '"' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 找结束引号(处理转义)
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(data[:findStringEnd(data)], &s) == nil {
|
||||||
|
*out = s
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// findStringEnd 从 " 开始找到结束引号的位置(含)
|
||||||
|
func findStringEnd(data []byte) int {
|
||||||
|
escaped := false
|
||||||
|
for i := 1; i < len(data); i++ {
|
||||||
|
if escaped {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if data[i] == '\\' {
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if data[i] == '"' {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 缓存 ---
|
||||||
|
|
||||||
|
const cacheVersion = 1
|
||||||
|
|
||||||
|
type cacheFile struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Sessions map[string]*cacheEntry `json:"sessions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
ModTime time.Time `json:"modTime"`
|
||||||
|
CustomTitle string `json:"customTitle"`
|
||||||
|
Cwd string `json:"cwd"`
|
||||||
|
StartTime time.Time `json:"startTime"`
|
||||||
|
EndTime time.Time `json:"endTime"`
|
||||||
|
MsgCount int `json:"msgCount"`
|
||||||
|
FirstMsg string `json:"firstMsg"`
|
||||||
|
AwaySummary string `json:"awaySummary"`
|
||||||
|
AISummary string `json:"aiSummary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *cacheEntry) ToSession(id string) *Session {
|
||||||
|
return &Session{
|
||||||
|
ID: id, CustomTitle: e.CustomTitle, Cwd: e.Cwd,
|
||||||
|
StartTime: e.StartTime, EndTime: e.EndTime,
|
||||||
|
MsgCount: e.MsgCount, FirstMsg: e.FirstMsg,
|
||||||
|
AwaySummary: e.AwaySummary, AISummary: e.AISummary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheEntryFrom(s *Session, modTime time.Time) *cacheEntry {
|
||||||
|
return &cacheEntry{
|
||||||
|
ModTime: modTime, CustomTitle: s.CustomTitle, Cwd: s.Cwd,
|
||||||
|
StartTime: s.StartTime, EndTime: s.EndTime,
|
||||||
|
MsgCount: s.MsgCount, FirstMsg: s.FirstMsg,
|
||||||
|
AwaySummary: s.AwaySummary, AISummary: s.AISummary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cachePath(home string) string {
|
||||||
|
return filepath.Join(home, ".u-tabs", "session-cache.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCache(home string) map[string]*cacheEntry {
|
||||||
|
data, err := os.ReadFile(cachePath(home))
|
||||||
|
if err != nil {
|
||||||
|
return make(map[string]*cacheEntry)
|
||||||
|
}
|
||||||
|
var cf cacheFile
|
||||||
|
if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion {
|
||||||
|
return make(map[string]*cacheEntry)
|
||||||
|
}
|
||||||
|
return cf.Sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveCache(home string, m map[string]*cacheEntry) {
|
||||||
|
cf := cacheFile{Version: cacheVersion, Sessions: m}
|
||||||
|
data, err := json.Marshal(cf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := filepath.Join(home, ".u-tabs")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
log.Printf("[u-tabs] cache mkdir fail: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cachePath(home), data, 0o644); err != nil {
|
||||||
|
log.Printf("[u-tabs] cache write fail: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncStr(s string, maxRunes int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= maxRunes {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:maxRunes]) + "..."
|
||||||
|
}
|
||||||
@@ -47,4 +47,14 @@ var GroupStyles = map[string]lipgloss.Style{
|
|||||||
"TOOLS": lipgloss.NewStyle().Bold(true).Foreground(Warning),
|
"TOOLS": lipgloss.NewStyle().Bold(true).Foreground(Warning),
|
||||||
"ME": lipgloss.NewStyle().Bold(true).Foreground(Purple),
|
"ME": lipgloss.NewStyle().Bold(true).Foreground(Purple),
|
||||||
"TEMP": lipgloss.NewStyle().Bold(true).Foreground(Cyan),
|
"TEMP": lipgloss.NewStyle().Bold(true).Foreground(Cyan),
|
||||||
|
"HISTORY": lipgloss.NewStyle().Bold(true).Foreground(Cyan),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 历史会话专用样式 ---
|
||||||
|
var (
|
||||||
|
SessionTimeStyle = lipgloss.NewStyle().Foreground(Dim).Width(12).Inline(true)
|
||||||
|
SessionMsgCntStyle = lipgloss.NewStyle().Foreground(Accent).Inline(true)
|
||||||
|
SessionSummaryStyle = lipgloss.NewStyle().Foreground(Dim).Italic(true).Inline(true)
|
||||||
|
DirCountStyle = lipgloss.NewStyle().Foreground(Accent).Inline(true)
|
||||||
|
ScanningStyle = lipgloss.NewStyle().Foreground(Warning).Bold(true)
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user