Files
DeepSeek-Reasonix/internal/agent/branch.go
T
SivanColaandreasonix b87a878cad feat(desktop): project topic tabs and context inspector (#2925)
* feat(desktop): add project tabs and context inspector

* feat(desktop): refine project workspace UI

* fix 打开一致加载

* 顶部统一logo+左侧边栏+统一菜单

* 底部最低宽度

* update much

* update code

* update code

* feat(desktop): refine workspace layout and message actions

* fix(desktop): scope file refs to active tab root

* test(desktop): stabilize capabilities config dirs

* fix(desktop): protect sessions open in other tabs

* fix(desktop): resume scoped history in matching tab

* chore(desktop): drop committed scratch artifacts; ignore tmp/ and .codex/

The project-tabs branch carried 30 throwaway screenshots, a mock HTML,
and a codex env file into the tree because tmp/ and .codex/ were never
ignored. Remove them and add the ignore rules so scratch output stays
out of future commits.

* test(desktop): isolate AppData per test so the suite passes on Windows

The new project/topic/workspace tests only overrode HOME and
XDG_CONFIG_HOME, but desktopConfigDir() and config.MemoryUserDir()
resolve through os.UserConfigDir(), which reads %AppData% on Windows and
ignores those vars. With AppData pointed at one shared TestMain dir, the
project/topic/workspace registries leaked across tests: full-package
runs failed (project tree len 2 want 1, trash mixing other tests'
sessions, Plugins[0] reading a neighbor's MCP entry) while each test
passed alone.

Route every test through isolateDesktopUserDirs, which already sets
HOME, USERPROFILE, XDG_CONFIG_HOME, and AppData to a per-test temp dir.
go test ./... (and -race) now pass on Windows.

---------

Co-authored-by: reasonix <[email protected]>
2026-06-04 19:41:16 -07:00

219 lines
5.0 KiB
Go

package agent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"reasonix/internal/fileutil"
)
// BranchMeta is the small sidecar record that turns flat session files into a
// navigable conversation tree. The conversation itself remains in the .jsonl
// file; metadata lives beside it at <session>.meta.
type BranchMeta struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
ParentID string `json:"parent_id,omitempty"`
ForkTurn int `json:"fork_turn,omitempty"`
ForkMessageIndex int `json:"fork_message_index,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Scope string `json:"scope,omitempty"`
WorkspaceRoot string `json:"workspace_root,omitempty"`
TopicID string `json:"topic_id,omitempty"`
TopicTitle string `json:"topic_title,omitempty"`
}
func (m BranchMeta) DefaultScope() string {
switch m.Scope {
case "project":
return "project"
default:
return "global"
}
}
// BranchInfo combines sidecar metadata with the session file details needed for
// pickers and tree rendering.
type BranchInfo struct {
BranchMeta
Path string
ModTime time.Time
Preview string
Turns int
}
func BranchID(path string) string {
if path == "" {
return ""
}
base := filepath.Base(path)
if ext := filepath.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
return base
}
func BranchMetaPath(sessionPath string) string {
if sessionPath == "" {
return ""
}
return sessionPath + ".meta"
}
func LoadBranchMeta(sessionPath string) (BranchMeta, bool, error) {
metaPath := BranchMetaPath(sessionPath)
if metaPath == "" {
return BranchMeta{}, false, nil
}
b, err := os.ReadFile(metaPath)
if err != nil {
if os.IsNotExist(err) {
return BranchMeta{}, false, nil
}
return BranchMeta{}, false, err
}
var m BranchMeta
if err := json.Unmarshal(b, &m); err != nil {
return BranchMeta{}, false, fmt.Errorf("decode branch meta %s: %w", metaPath, err)
}
if m.ID == "" {
m.ID = BranchID(sessionPath)
}
return m, true, nil
}
func SaveBranchMeta(sessionPath string, m BranchMeta) error {
return saveBranchMeta(sessionPath, m, true)
}
func SaveBranchMetaPreserveUpdated(sessionPath string, m BranchMeta) error {
return saveBranchMeta(sessionPath, m, false)
}
func saveBranchMeta(sessionPath string, m BranchMeta, touchUpdated bool) error {
metaPath := BranchMetaPath(sessionPath)
if metaPath == "" {
return fmt.Errorf("empty session path")
}
now := time.Now().UTC()
if m.ID == "" {
m.ID = BranchID(sessionPath)
}
if m.CreatedAt.IsZero() {
m.CreatedAt = now
}
if touchUpdated || m.UpdatedAt.IsZero() {
m.UpdatedAt = now
}
if err := os.MkdirAll(filepath.Dir(metaPath), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
tmp, err := os.CreateTemp(filepath.Dir(metaPath), ".branch.*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.Write(b); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
return fileutil.ReplaceFile(tmpPath, metaPath)
}
func EnsureBranchMeta(sessionPath string) (BranchMeta, error) {
if sessionPath == "" {
return BranchMeta{}, fmt.Errorf("empty session path")
}
if m, ok, err := LoadBranchMeta(sessionPath); err != nil || ok {
return m, err
}
when := time.Now().UTC()
if info, err := os.Stat(sessionPath); err == nil {
when = info.ModTime().UTC()
}
m := BranchMeta{
ID: BranchID(sessionPath),
CreatedAt: when,
UpdatedAt: when,
}
return m, saveBranchMeta(sessionPath, m, false)
}
func TouchBranchMeta(sessionPath string) error {
m, err := EnsureBranchMeta(sessionPath)
if err != nil {
return err
}
m.UpdatedAt = time.Now().UTC()
return saveBranchMeta(sessionPath, m, false)
}
func ListBranches(dir string) ([]BranchInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []BranchInfo
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".jsonl" {
continue
}
info, err := e.Info()
if err != nil {
continue
}
path := filepath.Join(dir, e.Name())
preview, turns := previewSession(path)
if turns == 0 {
continue
}
meta, ok, err := LoadBranchMeta(path)
if err != nil {
continue
}
if !ok {
meta = BranchMeta{
ID: BranchID(path),
CreatedAt: info.ModTime().UTC(),
UpdatedAt: info.ModTime().UTC(),
}
}
if meta.ID == "" {
meta.ID = BranchID(path)
}
out = append(out, BranchInfo{
BranchMeta: meta,
Path: path,
ModTime: info.ModTime(),
Preview: preview,
Turns: turns,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].ID < out[j].ID
}
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
return out, nil
}