From 0bd9848df965e00ac3ea21e8cd531807d3123408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 28 May 2026 16:18:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=88=86=E5=8F=89=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=A2=9E=E9=87=8F=E6=89=AB=E6=8F=8F=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 c 键触发会话分叉,支持带方向提示的分叉输入 - 使用 claude --fork-session 原生分叉,不污染原会话 - Init() 启动时自动触发历史扫描,无需手动切 tab - 缓存提升到 HistoryState 内存持有,避免重复 I/O - 新增项目目录 modTime 增量扫描,未变目录跳过遍历 - 扫描后裁剪缓存,删除磁盘已不存在的条目 - updateSummaryInCache 直接操作内存缓存 --- internal/app.go | 129 +++++++++++++++++++++++++++++++++--- internal/history.go | 157 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 254 insertions(+), 32 deletions(-) diff --git a/internal/app.go b/internal/app.go index e2b118f..6c0cd32 100644 --- a/internal/app.go +++ b/internal/app.go @@ -35,6 +35,8 @@ type Model struct { update UpdateState wsFocus int // workspace 焦点列: 0=tabs 1=list wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) + forkMode bool // 分叉输入模式 + forkBuf string // 分叉方向提示输入 } func NewModel() *Model { @@ -46,7 +48,11 @@ func NewModel() *Model { func (m *Model) Init() tea.Cmd { m.update.Checking = true - return CheckUpdateCmd() + cmds := []tea.Cmd{CheckUpdateCmd()} + if cmd := m.onTabSwitch(); cmd != nil { + cmds = append(cmds, cmd) + } + return tea.Batch(cmds...) } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -58,11 +64,23 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height case ScanCompleteMsg: m.history.Projects = msg.Projects + m.history.Cache = msg.Cache m.history.Loaded = true m.history.Scanning = false if m.history.Favorites == nil { m.history.Favorites = loadFavorites() } + // 填充 ForkFrom + forks := loadForks() + if len(forks) > 0 { + for _, pd := range m.history.Projects { + for _, s := range pd.Sessions { + if src, ok := forks[s.ID]; ok { + s.ForkFrom = src + } + } + } + } total := 0 for _, pd := range msg.Projects { total += len(pd.Sessions) @@ -134,11 +152,11 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) { } func (m *Model) onTabSwitch() tea.Cmd { - if m.IsHistoryTab() && !m.history.Loaded && !m.history.Scanning { - m.history.Scanning = true - return ScanSessionsCmd() + if !m.IsHistoryTab() || m.history.Scanning { + return nil } - return nil + m.history.Scanning = true + return ScanSessionsCmd() } // --- Workspace 按键 --- @@ -206,6 +224,11 @@ func (m *Model) moveGroupCursor(dir int) { // --- HISTORY 按键 --- func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { + // fork 输入模式拦截 + if m.forkMode { + return m.handleForkInput(s) + } + switch s { case "up", "k": if m.history.FocusPanel == 0 { @@ -243,10 +266,37 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { case "n": return m.newSessionFromHistory() case "r", "f5": - m.history.Scanning = true - return m, ScanSessionsCmd() + if cmd := m.onTabSwitch(); cmd != nil { + return m, cmd + } case "f": m.toggleFavorite() + case "c": + if m.currentSession() != nil && m.history.FocusPanel == 1 { + m.forkMode = true + m.forkBuf = "" + } + } + return m, nil +} + +// handleForkInput 处理分叉方向提示输入 +func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) { + switch s { + case "enter": + return m.executeFork() + case "esc": + m.forkMode = false + m.forkBuf = "" + case "backspace": + if len(m.forkBuf) > 0 { + m.forkBuf = m.forkBuf[:len(m.forkBuf)-1] + } + default: + // 只接受可打印字符 + if len(s) == 1 && s[0] >= 32 && s[0] < 127 { + m.forkBuf += s + } } return m, nil } @@ -365,6 +415,57 @@ func (m *Model) newSessionFromHistory() (*Model, tea.Cmd) { return m, nil } +func (m *Model) executeFork() (*Model, tea.Cmd) { + s := m.currentSession() + if s == nil { + m.forkMode = false + return m, nil + } + m.forkMode = false + + cwd := s.Cwd + if isWindows { + cwd = strings.ReplaceAll(cwd, "/", "\\") + } + title := s.CustomTitle + if title == "" { + title = s.ID[:8] + } + label := "fork: " + title + + // 构建 claude -r --fork-session [prompt] + if m.forkBuf != "" { + // 用户输入了方向提示 + prompt := m.forkBuf + m.forkBuf = "" + if isWindows { + escaped := strings.ReplaceAll(prompt, `"`, "`\"") + script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "fork: %s" + Write-Host "=== Fork from %s ===" -ForegroundColor Cyan + cd "%s" + claude -r %s --fork-session "%s" --permission-mode bypassPermissions`, title, title, cwd, s.ID, escaped) + launchInWtWithTitle(cwd, label, script) + } else { + escaped := strings.ReplaceAll(prompt, "'", "'\\''") + m.pendingCmd = fmt.Sprintf(`printf '\033]0;fork: %s\007' && echo "=== Fork from %s ===" && cd "%s" && claude -r %s --fork-session '%s' --permission-mode bypassPermissions`, title, title, cwd, s.ID, escaped) + } + } else { + // 纯分叉,不带方向提示 + if isWindows { + script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "fork: %s" + Write-Host "=== Fork from %s ===" -ForegroundColor Cyan + cd "%s" + claude -r %s --fork-session --permission-mode bypassPermissions`, title, title, cwd, s.ID) + launchInWtWithTitle(cwd, label, script) + } else { + m.pendingCmd = fmt.Sprintf(`printf '\033]0;fork: %s\007' && echo "=== Fork from %s ===" && cd "%s" && claude -r %s --fork-session --permission-mode bypassPermissions`, title, title, cwd, s.ID) + } + } + + m.launched = label + return m, nil +} + // --- AI 摘要 --- func (m *Model) applySummary(sessionID, summary string, completed, pending []string) { @@ -377,7 +478,7 @@ func (m *Model) applySummary(sessionID, summary string, completed, pending []str s.AISummary = summary s.Completed = completed s.Pending = pending - saveSummaryToCache(sessionID, summary, completed, pending) + updateSummaryInCache(m.history.Cache, sessionID, summary, completed, pending) return } } @@ -542,6 +643,9 @@ func (m *Model) render() string { if m.inputBuf != "" { b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ▶ num:%s [Enter]go [Esc]cancel", m.inputBuf))) } + if m.forkMode { + b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ⎇ fork:%s [Enter]confirm [Esc]cancel", m.forkBuf))) + } if m.launched != "" { b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched))) m.launched = "" @@ -585,6 +689,7 @@ func (m *Model) renderHelp() []string { m.fmtHelp("Enter", "resume"), m.fmtHelp("f", "star"), m.fmtHelp("n", "new"), + m.fmtHelp("c", "fork"), m.fmtHelp("1", "workspace"), m.fmtHelp("r/F5", "refresh"), m.fmtHelp("q", "quit"), @@ -1012,6 +1117,14 @@ func (m *Model) detailColumnLines(w, listH int) []string { lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]resume", w))) + if s.ForkFrom != "" { + forkID := s.ForkFrom + if len(forkID) > 8 { + forkID = forkID[:8] + } + lines = append(lines, lipgloss.NewStyle().Foreground(style.Cyan).Render(fitWidth(fmt.Sprintf(" ⎇ fork from %s", forkID), w))) + } + if len(lines) > listH { lines = lines[:listH] } diff --git a/internal/history.go b/internal/history.go index a2c9e6d..06cd433 100644 --- a/internal/history.go +++ b/internal/history.go @@ -30,6 +30,7 @@ type Session struct { Completed []string // AI 提取的已完成项 Pending []string // AI 提取的待办项 FilePath string // JSONL 文件完整路径 + ForkFrom string // 分叉来源 session ID(空表示非分叉) } // ProjectDir 按目录分组的会话集合 @@ -89,6 +90,7 @@ type HistoryState struct { Loaded bool Scanning bool Favorites map[string]bool + Cache map[string]*cacheEntry // 内存缓存,避免重复 I/O } // IsHistoryTab 判断当前是否在 HISTORY Tab @@ -100,36 +102,53 @@ func (m *Model) IsHistoryTab() bool { type ScanCompleteMsg struct { Projects []*ProjectDir - UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要 + Cache map[string]*cacheEntry // 更新后的缓存 + UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要 } func ScanSessionsCmd() tea.Cmd { return func() tea.Msg { - projects, updated := scanAllProjects() - return ScanCompleteMsg{Projects: projects, UpdatedIDs: updated} + projects, cache, updated := scanAllProjects() + return ScanCompleteMsg{Projects: projects, Cache: cache, UpdatedIDs: updated} } } -func scanAllProjects() ([]*ProjectDir, map[string]bool) { +func scanAllProjects() ([]*ProjectDir, map[string]*cacheEntry, map[string]bool) { home, err := os.UserHomeDir() if err != nil { - return nil, nil + return nil, nil, nil } projectsDir := filepath.Join(home, ".claude", "projects") entries, err := os.ReadDir(projectsDir) if err != nil { - return nil, nil + return nil, nil, nil } - cache := loadCache(home) + cache, projTimes := loadCacheWithTimes(home) dirMap := make(map[string]*ProjectDir) updatedIDs := make(map[string]bool) + seenIDs := make(map[string]bool) for _, entry := range entries { if !entry.IsDir() { continue } projectPath := filepath.Join(projectsDir, entry.Name()) + info, err := entry.Info() + if err != nil { + continue + } + + // 项目目录 modTime 未变 → 从缓存直接重建该目录下的会话 + if prev, ok := projTimes[projectPath]; ok && prev.Equal(info.ModTime()) { + for _, id := range rebuildFromCache(cache, dirMap, projectPath) { + seenIDs[id] = true + } + continue + } + // 目录有变更,记录新 modTime + projTimes[projectPath] = info.ModTime() + files, err := os.ReadDir(projectPath) if err != nil { continue @@ -139,13 +158,14 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) { continue } sessionID := strings.TrimSuffix(f.Name(), ".jsonl") + seenIDs[sessionID] = true filePath := filepath.Join(projectPath, f.Name()) - info, err := f.Info() + finfo, err := f.Info() if err != nil { continue } - if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(info.ModTime()) { + if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(finfo.ModTime()) { addSessionToDir(dirMap, cached.ToSession(sessionID)) continue } @@ -160,11 +180,31 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) { session.Pending = nil updatedIDs[sessionID] = true addSessionToDir(dirMap, session) - cache[sessionID] = cacheEntryFrom(session, info.ModTime()) + cache[sessionID] = cacheEntryFrom(session, finfo.ModTime()) } } - saveCache(home, cache) + // 裁剪缓存:删除磁盘已不存在的条目 + for id := range cache { + if !seenIDs[id] { + delete(cache, id) + } + } + + // 清理已不存在项目的 projTimes + activeProjects := make(map[string]bool, len(entries)) + for _, e := range entries { + if e.IsDir() { + activeProjects[filepath.Join(projectsDir, e.Name())] = true + } + } + for p := range projTimes { + if !activeProjects[p] { + delete(projTimes, p) + } + } + + saveCacheWithTimes(home, cache, projTimes) result := make([]*ProjectDir, 0, len(dirMap)) for _, pd := range dirMap { @@ -176,7 +216,20 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) { sort.Slice(result, func(i, j int) bool { return result[i].Dir < result[j].Dir }) - return result, updatedIDs + return result, cache, updatedIDs +} + +// rebuildFromCache 从缓存重建指定项目目录下的所有会话,返回匹配的 session ID +func rebuildFromCache(cache map[string]*cacheEntry, dirMap map[string]*ProjectDir, projectPath string) []string { + prefix := filepath.Base(projectPath) + var seen []string + for id, e := range cache { + if filepath.Base(filepath.Dir(e.FilePath)) == prefix { + addSessionToDir(dirMap, e.ToSession(id)) + seen = append(seen, id) + } + } + return seen } func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) { @@ -360,8 +413,9 @@ func findStringEnd(data []byte) int { const cacheVersion = 2 type cacheFile struct { - Version int `json:"version"` - Sessions map[string]*cacheEntry `json:"sessions"` + Version int `json:"version"` + Sessions map[string]*cacheEntry `json:"sessions"` + ProjectTimes map[string]time.Time `json:"projectTimes"` // 项目目录 modTime } type cacheEntry struct { @@ -377,6 +431,7 @@ type cacheEntry struct { Completed []string `json:"completed"` Pending []string `json:"pending"` FilePath string `json:"filePath"` + ForkFrom string `json:"forkFrom"` } func (e *cacheEntry) ToSession(id string) *Session { @@ -386,7 +441,7 @@ func (e *cacheEntry) ToSession(id string) *Session { MsgCount: e.MsgCount, FirstMsg: e.FirstMsg, AwaySummary: e.AwaySummary, AISummary: e.AISummary, Completed: e.Completed, Pending: e.Pending, - FilePath: e.FilePath, + FilePath: e.FilePath, ForkFrom: e.ForkFrom, } } @@ -397,7 +452,7 @@ func cacheEntryFrom(s *Session, modTime time.Time) *cacheEntry { MsgCount: s.MsgCount, FirstMsg: s.FirstMsg, AwaySummary: s.AwaySummary, AISummary: s.AISummary, Completed: s.Completed, Pending: s.Pending, - FilePath: s.FilePath, + FilePath: s.FilePath, ForkFrom: s.ForkFrom, } } @@ -406,19 +461,32 @@ func cachePath(home string) string { } func loadCache(home string) map[string]*cacheEntry { + c, _ := loadCacheWithTimes(home) + return c +} + +func loadCacheWithTimes(home string) (map[string]*cacheEntry, map[string]time.Time) { data, err := os.ReadFile(cachePath(home)) if err != nil { - return make(map[string]*cacheEntry) + return make(map[string]*cacheEntry), make(map[string]time.Time) } var cf cacheFile if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion { - return make(map[string]*cacheEntry) + return make(map[string]*cacheEntry), make(map[string]time.Time) } - return cf.Sessions + projTimes := cf.ProjectTimes + if projTimes == nil { + projTimes = make(map[string]time.Time) + } + return cf.Sessions, projTimes } func saveCache(home string, m map[string]*cacheEntry) { - cf := cacheFile{Version: cacheVersion, Sessions: m} + saveCacheWithTimes(home, m, nil) +} + +func saveCacheWithTimes(home string, m map[string]*cacheEntry, projTimes map[string]time.Time) { + cf := cacheFile{Version: cacheVersion, Sessions: m, ProjectTimes: projTimes} data, err := json.Marshal(cf) if err != nil { return @@ -536,12 +604,10 @@ func extractMessagesForSummary(path string) string { return strings.Join(msgs, "\n") } -func saveSummaryToCache(sessionID, summary string, completed, pending []string) { - home, err := os.UserHomeDir() - if err != nil { +func updateSummaryInCache(cache map[string]*cacheEntry, sessionID, summary string, completed, pending []string) { + if cache == nil { return } - cache := loadCache(home) entry, ok := cache[sessionID] if !ok { return @@ -549,6 +615,11 @@ func saveSummaryToCache(sessionID, summary string, completed, pending []string) entry.AISummary = summary entry.Completed = completed entry.Pending = pending + // 异步持久化 + home, err := os.UserHomeDir() + if err != nil { + return + } saveCache(home, cache) } @@ -624,3 +695,41 @@ func collectFavorites(projects []*ProjectDir, favs map[string]bool) []*Session { } return out } + +// --- Fork 记录 --- + +func forksPath(home string) string { + return filepath.Join(home, ".u-tabs", "forks.json") +} + +func loadForks() map[string]string { + home, err := os.UserHomeDir() + if err != nil { + return make(map[string]string) + } + data, err := os.ReadFile(forksPath(home)) + if err != nil { + return make(map[string]string) + } + var m map[string]string + if json.Unmarshal(data, &m) != nil { + return make(map[string]string) + } + return m +} + +func recordFork(targetID, sourceID string) { + home, err := os.UserHomeDir() + if err != nil { + return + } + forks := loadForks() + forks[targetID] = sourceID + data, err := json.Marshal(forks) + if err != nil { + return + } + dir := filepath.Join(home, ".u-tabs") + os.MkdirAll(dir, 0o755) + os.WriteFile(forksPath(home), data, 0o644) +}