新增: 会话分叉功能,优化增量扫描缓存
- 新增 c 键触发会话分叉,支持带方向提示的分叉输入 - 使用 claude --fork-session 原生分叉,不污染原会话 - Init() 启动时自动触发历史扫描,无需手动切 tab - 缓存提升到 HistoryState 内存持有,避免重复 I/O - 新增项目目录 modTime 增量扫描,未变目录跳过遍历 - 扫描后裁剪缓存,删除磁盘已不存在的条目 - updateSummaryInCache 直接操作内存缓存
This commit is contained in:
129
internal/app.go
129
internal/app.go
@@ -35,6 +35,8 @@ type Model struct {
|
|||||||
update UpdateState
|
update UpdateState
|
||||||
wsFocus int // workspace 焦点列: 0=tabs 1=list
|
wsFocus int // workspace 焦点列: 0=tabs 1=list
|
||||||
wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口)
|
wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口)
|
||||||
|
forkMode bool // 分叉输入模式
|
||||||
|
forkBuf string // 分叉方向提示输入
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel() *Model {
|
func NewModel() *Model {
|
||||||
@@ -46,7 +48,11 @@ func NewModel() *Model {
|
|||||||
|
|
||||||
func (m *Model) Init() tea.Cmd {
|
func (m *Model) Init() tea.Cmd {
|
||||||
m.update.Checking = true
|
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) {
|
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
|
m.height = msg.Height
|
||||||
case ScanCompleteMsg:
|
case ScanCompleteMsg:
|
||||||
m.history.Projects = msg.Projects
|
m.history.Projects = msg.Projects
|
||||||
|
m.history.Cache = msg.Cache
|
||||||
m.history.Loaded = true
|
m.history.Loaded = true
|
||||||
m.history.Scanning = false
|
m.history.Scanning = false
|
||||||
if m.history.Favorites == nil {
|
if m.history.Favorites == nil {
|
||||||
m.history.Favorites = loadFavorites()
|
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
|
total := 0
|
||||||
for _, pd := range msg.Projects {
|
for _, pd := range msg.Projects {
|
||||||
total += len(pd.Sessions)
|
total += len(pd.Sessions)
|
||||||
@@ -134,11 +152,11 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) onTabSwitch() tea.Cmd {
|
func (m *Model) onTabSwitch() tea.Cmd {
|
||||||
if m.IsHistoryTab() && !m.history.Loaded && !m.history.Scanning {
|
if !m.IsHistoryTab() || m.history.Scanning {
|
||||||
m.history.Scanning = true
|
return nil
|
||||||
return ScanSessionsCmd()
|
|
||||||
}
|
}
|
||||||
return nil
|
m.history.Scanning = true
|
||||||
|
return ScanSessionsCmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Workspace 按键 ---
|
// --- Workspace 按键 ---
|
||||||
@@ -206,6 +224,11 @@ func (m *Model) moveGroupCursor(dir int) {
|
|||||||
// --- HISTORY 按键 ---
|
// --- HISTORY 按键 ---
|
||||||
|
|
||||||
func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
||||||
|
// fork 输入模式拦截
|
||||||
|
if m.forkMode {
|
||||||
|
return m.handleForkInput(s)
|
||||||
|
}
|
||||||
|
|
||||||
switch s {
|
switch s {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.history.FocusPanel == 0 {
|
if m.history.FocusPanel == 0 {
|
||||||
@@ -243,10 +266,37 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
|||||||
case "n":
|
case "n":
|
||||||
return m.newSessionFromHistory()
|
return m.newSessionFromHistory()
|
||||||
case "r", "f5":
|
case "r", "f5":
|
||||||
m.history.Scanning = true
|
if cmd := m.onTabSwitch(); cmd != nil {
|
||||||
return m, ScanSessionsCmd()
|
return m, cmd
|
||||||
|
}
|
||||||
case "f":
|
case "f":
|
||||||
m.toggleFavorite()
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -365,6 +415,57 @@ func (m *Model) newSessionFromHistory() (*Model, tea.Cmd) {
|
|||||||
return m, nil
|
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 <id> --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 摘要 ---
|
// --- AI 摘要 ---
|
||||||
|
|
||||||
func (m *Model) applySummary(sessionID, summary string, completed, pending []string) {
|
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.AISummary = summary
|
||||||
s.Completed = completed
|
s.Completed = completed
|
||||||
s.Pending = pending
|
s.Pending = pending
|
||||||
saveSummaryToCache(sessionID, summary, completed, pending)
|
updateSummaryInCache(m.history.Cache, sessionID, summary, completed, pending)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,6 +643,9 @@ func (m *Model) render() string {
|
|||||||
if m.inputBuf != "" {
|
if m.inputBuf != "" {
|
||||||
b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ▶ num:%s [Enter]go [Esc]cancel", 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 != "" {
|
if m.launched != "" {
|
||||||
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 = ""
|
||||||
@@ -585,6 +689,7 @@ func (m *Model) renderHelp() []string {
|
|||||||
m.fmtHelp("Enter", "resume"),
|
m.fmtHelp("Enter", "resume"),
|
||||||
m.fmtHelp("f", "star"),
|
m.fmtHelp("f", "star"),
|
||||||
m.fmtHelp("n", "new"),
|
m.fmtHelp("n", "new"),
|
||||||
|
m.fmtHelp("c", "fork"),
|
||||||
m.fmtHelp("1", "workspace"),
|
m.fmtHelp("1", "workspace"),
|
||||||
m.fmtHelp("r/F5", "refresh"),
|
m.fmtHelp("r/F5", "refresh"),
|
||||||
m.fmtHelp("q", "quit"),
|
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)))
|
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 {
|
if len(lines) > listH {
|
||||||
lines = lines[:listH]
|
lines = lines[:listH]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type Session struct {
|
|||||||
Completed []string // AI 提取的已完成项
|
Completed []string // AI 提取的已完成项
|
||||||
Pending []string // AI 提取的待办项
|
Pending []string // AI 提取的待办项
|
||||||
FilePath string // JSONL 文件完整路径
|
FilePath string // JSONL 文件完整路径
|
||||||
|
ForkFrom string // 分叉来源 session ID(空表示非分叉)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectDir 按目录分组的会话集合
|
// ProjectDir 按目录分组的会话集合
|
||||||
@@ -89,6 +90,7 @@ type HistoryState struct {
|
|||||||
Loaded bool
|
Loaded bool
|
||||||
Scanning bool
|
Scanning bool
|
||||||
Favorites map[string]bool
|
Favorites map[string]bool
|
||||||
|
Cache map[string]*cacheEntry // 内存缓存,避免重复 I/O
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsHistoryTab 判断当前是否在 HISTORY Tab
|
// IsHistoryTab 判断当前是否在 HISTORY Tab
|
||||||
@@ -100,36 +102,53 @@ func (m *Model) IsHistoryTab() bool {
|
|||||||
|
|
||||||
type ScanCompleteMsg struct {
|
type ScanCompleteMsg struct {
|
||||||
Projects []*ProjectDir
|
Projects []*ProjectDir
|
||||||
UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要
|
Cache map[string]*cacheEntry // 更新后的缓存
|
||||||
|
UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要
|
||||||
}
|
}
|
||||||
|
|
||||||
func ScanSessionsCmd() tea.Cmd {
|
func ScanSessionsCmd() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
projects, updated := scanAllProjects()
|
projects, cache, updated := scanAllProjects()
|
||||||
return ScanCompleteMsg{Projects: projects, UpdatedIDs: updated}
|
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()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
projectsDir := filepath.Join(home, ".claude", "projects")
|
projectsDir := filepath.Join(home, ".claude", "projects")
|
||||||
entries, err := os.ReadDir(projectsDir)
|
entries, err := os.ReadDir(projectsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := loadCache(home)
|
cache, projTimes := loadCacheWithTimes(home)
|
||||||
dirMap := make(map[string]*ProjectDir)
|
dirMap := make(map[string]*ProjectDir)
|
||||||
updatedIDs := make(map[string]bool)
|
updatedIDs := make(map[string]bool)
|
||||||
|
seenIDs := make(map[string]bool)
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
projectPath := filepath.Join(projectsDir, entry.Name())
|
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)
|
files, err := os.ReadDir(projectPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -139,13 +158,14 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sessionID := strings.TrimSuffix(f.Name(), ".jsonl")
|
sessionID := strings.TrimSuffix(f.Name(), ".jsonl")
|
||||||
|
seenIDs[sessionID] = true
|
||||||
filePath := filepath.Join(projectPath, f.Name())
|
filePath := filepath.Join(projectPath, f.Name())
|
||||||
info, err := f.Info()
|
finfo, err := f.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
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))
|
addSessionToDir(dirMap, cached.ToSession(sessionID))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -160,11 +180,31 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) {
|
|||||||
session.Pending = nil
|
session.Pending = nil
|
||||||
updatedIDs[sessionID] = true
|
updatedIDs[sessionID] = true
|
||||||
addSessionToDir(dirMap, session)
|
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))
|
result := make([]*ProjectDir, 0, len(dirMap))
|
||||||
for _, pd := range dirMap {
|
for _, pd := range dirMap {
|
||||||
@@ -176,7 +216,20 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) {
|
|||||||
sort.Slice(result, func(i, j int) bool {
|
sort.Slice(result, func(i, j int) bool {
|
||||||
return result[i].Dir < result[j].Dir
|
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) {
|
func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) {
|
||||||
@@ -360,8 +413,9 @@ func findStringEnd(data []byte) int {
|
|||||||
const cacheVersion = 2
|
const cacheVersion = 2
|
||||||
|
|
||||||
type cacheFile struct {
|
type cacheFile struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Sessions map[string]*cacheEntry `json:"sessions"`
|
Sessions map[string]*cacheEntry `json:"sessions"`
|
||||||
|
ProjectTimes map[string]time.Time `json:"projectTimes"` // 项目目录 modTime
|
||||||
}
|
}
|
||||||
|
|
||||||
type cacheEntry struct {
|
type cacheEntry struct {
|
||||||
@@ -377,6 +431,7 @@ type cacheEntry struct {
|
|||||||
Completed []string `json:"completed"`
|
Completed []string `json:"completed"`
|
||||||
Pending []string `json:"pending"`
|
Pending []string `json:"pending"`
|
||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
|
ForkFrom string `json:"forkFrom"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *cacheEntry) ToSession(id string) *Session {
|
func (e *cacheEntry) ToSession(id string) *Session {
|
||||||
@@ -386,7 +441,7 @@ func (e *cacheEntry) ToSession(id string) *Session {
|
|||||||
MsgCount: e.MsgCount, FirstMsg: e.FirstMsg,
|
MsgCount: e.MsgCount, FirstMsg: e.FirstMsg,
|
||||||
AwaySummary: e.AwaySummary, AISummary: e.AISummary,
|
AwaySummary: e.AwaySummary, AISummary: e.AISummary,
|
||||||
Completed: e.Completed, Pending: e.Pending,
|
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,
|
MsgCount: s.MsgCount, FirstMsg: s.FirstMsg,
|
||||||
AwaySummary: s.AwaySummary, AISummary: s.AISummary,
|
AwaySummary: s.AwaySummary, AISummary: s.AISummary,
|
||||||
Completed: s.Completed, Pending: s.Pending,
|
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 {
|
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))
|
data, err := os.ReadFile(cachePath(home))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return make(map[string]*cacheEntry)
|
return make(map[string]*cacheEntry), make(map[string]time.Time)
|
||||||
}
|
}
|
||||||
var cf cacheFile
|
var cf cacheFile
|
||||||
if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion {
|
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) {
|
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)
|
data, err := json.Marshal(cf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -536,12 +604,10 @@ func extractMessagesForSummary(path string) string {
|
|||||||
return strings.Join(msgs, "\n")
|
return strings.Join(msgs, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveSummaryToCache(sessionID, summary string, completed, pending []string) {
|
func updateSummaryInCache(cache map[string]*cacheEntry, sessionID, summary string, completed, pending []string) {
|
||||||
home, err := os.UserHomeDir()
|
if cache == nil {
|
||||||
if err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cache := loadCache(home)
|
|
||||||
entry, ok := cache[sessionID]
|
entry, ok := cache[sessionID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -549,6 +615,11 @@ func saveSummaryToCache(sessionID, summary string, completed, pending []string)
|
|||||||
entry.AISummary = summary
|
entry.AISummary = summary
|
||||||
entry.Completed = completed
|
entry.Completed = completed
|
||||||
entry.Pending = pending
|
entry.Pending = pending
|
||||||
|
// 异步持久化
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
saveCache(home, cache)
|
saveCache(home, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,3 +695,41 @@ func collectFavorites(projects []*ProjectDir, favs map[string]bool) []*Session {
|
|||||||
}
|
}
|
||||||
return out
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user