Files
u-tabs/internal/history.go
T
lxy 45e32193af 修复: 缓存原子写与 fork 关系持久化,优化摘要重试
- 原子写: 新增 atomicWriteFile,缓存/收藏/forks 改为临时文件+rename,避免崩溃损坏
- fork 关系: executeFork 记录待绑定项,扫描后按 cwd+时间窗口匹配新会话并持久化
- 摘要重试: 去掉 needDetail 反复刷新逻辑,仅对未生成摘要的会话触发,避免浪费 claude -p 调用
- 脚本转义: pwsh/bash 的 Prompt 回显段转义双引号与 $,防止注入和语法错误
- 输入限制: 工作空间数字输入限制 4 位,避免无限增长
- 品牌统一: 底部版本号文案 绝尘 → U-TABS
2026-07-01 01:29:01 +08:00

864 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package internal
import (
"bufio"
"bytes"
"context"
"encoding/json"
"log"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
tea "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 生成的摘要 (缓存)
Completed []string // AI 提取的已完成项
Pending []string // AI 提取的待办项
FilePath string // JSONL 文件完整路径
ForkFrom string // 分叉来源 session ID(空表示非分叉)
}
// ProjectDir 按目录分组的会话集合
type ProjectDir struct {
Dir string // 完整路径
DirShort string // 显示名 (路径最后一段)
Sessions []*Session // 按时间倒序
}
// DisplayTitle 返回会话显示标题
func (s *Session) DisplayTitle() string {
return cleanText(s.displayTitleRaw())
}
func (s *Session) displayTitleRaw() 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 {
return cleanText(s.displaySummaryRaw())
}
func (s *Session) displaySummaryRaw() string {
if s.AwaySummary != "" {
return s.AwaySummary
}
return s.FirstMsg
}
// cleanText 清理显示文本:多空格保留一个,换行转空格
func cleanText(s string) string {
s = strings.ReplaceAll(s, "\r\n", " ")
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\t", " ")
for strings.Contains(s, " ") {
s = strings.ReplaceAll(s, " ", " ")
}
return strings.TrimSpace(s)
}
// HistoryState HISTORY Tab 视图状态
type HistoryState struct {
Projects []*ProjectDir
DirCursor int // 左栏光标
SessCursor int // 中栏光标
FocusPanel int // 0=左栏 1=中栏
Loaded bool
Scanning bool
Favorites map[string]bool
Cache map[string]*cacheEntry // 内存缓存,避免重复 I/O
}
// IsHistoryTab 判断当前是否在 HISTORY Tab
func (m *Model) IsHistoryTab() bool {
return m.activeGroup == len(Groups)
}
// --- 扫描 ---
type ScanCompleteMsg struct {
Projects []*ProjectDir
Cache map[string]*cacheEntry // 更新后的缓存
UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要
}
func ScanSessionsCmd() tea.Cmd {
return func() tea.Msg {
projects, cache, updated := scanAllProjects()
return ScanCompleteMsg{Projects: projects, Cache: cache, UpdatedIDs: updated}
}
}
func scanAllProjects() ([]*ProjectDir, map[string]*cacheEntry, map[string]bool) {
home, err := os.UserHomeDir()
if err != nil {
return nil, nil, nil
}
projectsDir := filepath.Join(home, ".claude", "projects")
entries, err := os.ReadDir(projectsDir)
if err != nil {
return nil, nil, nil
}
cache, projTimes := loadCacheWithTimes(home)
dirMap := make(map[string]*ProjectDir)
updatedIDs := make(map[string]bool)
seenIDs := make(map[string]bool)
scanBuf := make([]byte, 0, 64*1024) // 共享扫描缓冲区
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
}
for _, f := range files {
if f.IsDir() || !strings.HasSuffix(f.Name(), ".jsonl") {
continue
}
sessionID := strings.TrimSuffix(f.Name(), ".jsonl")
seenIDs[sessionID] = true
filePath := filepath.Join(projectPath, f.Name())
finfo, err := f.Info()
if err != nil {
continue
}
if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(finfo.ModTime()) {
addSessionToDir(dirMap, cached.ToSession(sessionID))
continue
}
session := scanSessionFile(filePath, sessionID, &scanBuf)
if session == nil || session.Cwd == "" {
continue
}
// 清除旧摘要,标记需要重新生成
session.AISummary = ""
session.Completed = nil
session.Pending = nil
updatedIDs[sessionID] = true
addSessionToDir(dirMap, session)
cache[sessionID] = cacheEntryFrom(session, finfo.ModTime())
}
}
// 裁剪缓存:删除磁盘已不存在的条目
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 {
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, 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) {
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, buf *[]byte) *Session {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
s := &Session{ID: sessionID, FilePath: path}
scanner := bufio.NewScanner(f)
scanner.Buffer(*buf, 1024*1024)
lineCount := 0
firstMsgFound := false
for scanner.Scan() {
lineCount++
line := scanner.Bytes()
// 前 20 行内出现 queue-operation → claude -p 会话,跳过
if lineCount <= 20 && matchType(line, "queue-operation") {
return nil
}
if !hasType(line) {
continue
}
// 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
}
}
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+7:], &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 = 2
type cacheFile struct {
Version int `json:"version"`
Sessions map[string]*cacheEntry `json:"sessions"`
ProjectTimes map[string]time.Time `json:"projectTimes"` // 项目目录 modTime
}
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"`
Completed []string `json:"completed"`
Pending []string `json:"pending"`
FilePath string `json:"filePath"`
ForkFrom string `json:"forkFrom"`
}
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,
Completed: e.Completed, Pending: e.Pending,
FilePath: e.FilePath, ForkFrom: e.ForkFrom,
}
}
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,
Completed: s.Completed, Pending: s.Pending,
FilePath: s.FilePath, ForkFrom: s.ForkFrom,
}
}
func cachePath(home string) string {
return filepath.Join(home, ".u-tabs", "session-cache.json")
}
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), make(map[string]time.Time)
}
var cf cacheFile
if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion {
return make(map[string]*cacheEntry), make(map[string]time.Time)
}
projTimes := cf.ProjectTimes
if projTimes == nil {
projTimes = make(map[string]time.Time)
}
return cf.Sessions, projTimes
}
func saveCache(home string, m map[string]*cacheEntry) {
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
}
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 := atomicWriteFile(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]) + "..."
}
// --- AI 摘要生成 ---
type SummaryResultMsg struct {
SessionID string
Summary string
Completed []string
Pending []string
}
func generateSummaryCmd(filePath, sessionID string) tea.Cmd {
return func() tea.Msg {
sum, done, todo := generateAISummary(filePath)
return SummaryResultMsg{SessionID: sessionID, Summary: sum, Completed: done, Pending: todo}
}
}
type aiSummaryResult struct {
Summary string `json:"summary"`
Completed []string `json:"completed"`
Pending []string `json:"pending"`
}
func generateAISummary(filePath string) (string, []string, []string) {
messages := extractMessagesForSummary(filePath)
if messages == "" {
return "", nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
prompt := `分析以下Claude Code对话,用JSON输出:
{"summary":"20字内中文概括","completed":["已完成项1","已完成项2"],"pending":["待办项1"]}
要求:summary 20字内概括;completed 已完成工作(每项≤30字);pending 未完成/待办(每项≤30字)。只输出JSON。` + "\n\n" + messages
cmd := exec.CommandContext(ctx, "claude", "-p", prompt)
output, err := cmd.Output()
if err != nil {
return "", nil, nil
}
raw := strings.TrimSpace(string(output))
raw = strings.Trim(raw, "`")
raw = strings.TrimPrefix(raw, "json")
raw = strings.TrimSpace(raw)
var result aiSummaryResult
if json.Unmarshal([]byte(raw), &result) != nil {
// Fallback: treat whole output as summary
s := strings.Trim(raw, "\"'`*")
s = strings.TrimPrefix(s, "概括:")
s = strings.TrimPrefix(s, "主题:")
if len(s) > 60 {
s = truncStr(s, 60)
}
return s, nil, nil
}
if len(result.Summary) > 60 {
result.Summary = truncStr(result.Summary, 60)
}
return result.Summary, result.Completed, result.Pending
}
func extractMessagesForSummary(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
var msgs []string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
userCount := 0
for scanner.Scan() {
if userCount >= 5 {
break
}
line := scanner.Bytes()
if !hasType(line) {
continue
}
if matchType(line, "user") {
if msg := extractUserMsg(line); msg != "" {
msgs = append(msgs, "用户: "+msg)
userCount++
}
}
}
return strings.Join(msgs, "\n")
}
func updateSummaryInCache(cache map[string]*cacheEntry, sessionID, summary string, completed, pending []string) {
if cache == nil {
return
}
entry, ok := cache[sessionID]
if !ok {
return
}
entry.AISummary = summary
entry.Completed = completed
entry.Pending = pending
// 持久化由 Model.flushCache() 延迟批量写入
}
// cacheFlushMsg 延迟写缓存定时器消息
type cacheFlushMsg struct{}
// --- 收藏 ---
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 := atomicWriteFile(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
}
// --- Fork 记录 ---
func forksPath(home string) string {
return filepath.Join(home, ".u-tabs", "forks.json")
}
// pendingFork 记录一次待绑定的 fork:启动 fork 后新会话 ID 未知,
// 等下次扫描时按 cwd + 时间窗口匹配出新建会话再建立关系。
type pendingFork struct {
SourceID string
Cwd string
At time.Time
}
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
}
if err := atomicWriteFile(forksPath(home), data, 0o644); err != nil {
log.Printf("[u-tabs] forks write fail: %v", err)
}
}
// resolvePendingForks 处理 executeFork 留下的待绑定项:
// 在最新扫描结果中,找出 cwd 匹配且 StartTime 晚于记录时刻的新会话,
// 将其作为 fork 目标记录关系到 forks.json。
// 匹配窗口 10 分钟,避免时钟偏差;同一 cwd 只匹配最早的一条新会话。
func resolvePendingForks(pending []pendingFork, projects []*ProjectDir) {
if len(pending) == 0 {
return
}
forks := loadForks()
changed := false
for _, p := range pending {
// 已绑过则跳过
matched := ""
for _, pd := range projects {
if normalizeCwd(pd.Dir) != normalizeCwd(p.Cwd) {
continue
}
for _, s := range pd.Sessions {
if s.StartTime.IsZero() {
continue
}
// 新会话起始时间晚于 fork 时刻(容许 2 秒时钟偏差)
if !s.StartTime.Before(p.At.Add(-2 * time.Second)) {
if s.ID == p.SourceID {
continue
}
if matched == "" || s.StartTime.Before(findSessionStart(projects, matched)) {
matched = s.ID
}
}
}
}
if matched != "" {
forks[matched] = p.SourceID
changed = true
}
}
if changed {
home, err := os.UserHomeDir()
if err == nil {
data, _ := json.Marshal(forks)
atomicWriteFile(forksPath(home), data, 0o644)
}
}
}
func normalizeCwd(s string) string {
return strings.ReplaceAll(s, "\\", "/")
}
func findSessionStart(projects []*ProjectDir, id string) time.Time {
for _, pd := range projects {
for _, s := range pd.Sessions {
if s.ID == id {
return s.StartTime
}
}
}
return time.Time{}
}
// --- AI 摘要与收藏操作 ---
func (m *Model) applySummary(sessionID, summary string, completed, pending []string) {
for _, pd := range m.history.Projects {
for _, s := range pd.Sessions {
if s.ID == sessionID {
if summary == "" {
// 标记为已尝试,防止 nextSummaryCmd 无限重试
// 用 FirstMsg 作为兜底显示,避免显示空白
s.AISummary = s.FirstMsg
if s.AISummary == "" {
s.AISummary = s.ID[:8]
}
} else {
s.AISummary = summary
s.Completed = completed
s.Pending = pending
}
updateSummaryInCache(m.history.Cache, sessionID, s.AISummary, s.Completed, s.Pending)
return
}
}
}
}
const maxSummaryPerSession = 20 // 每次启动最多生成 N 个摘要
func (m *Model) nextSummaryCmd() tea.Cmd {
if m.summaryCount >= maxSummaryPerSession {
return nil
}
for _, pd := range m.history.Projects {
for _, s := range pd.Sessions {
// 仅对从未生成过摘要的会话生成,避免对已有摘要的会话反复重试
// (fallback 路径已用 FirstMsg/ID 充充 AISummary,标记为已生成)
if s.AISummary == "" && s.FirstMsg != "" && s.FilePath != "" {
m.summaryCount++
return generateSummaryCmd(s.FilePath, s.ID)
}
}
}
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)
}