mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
* 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]>
219 lines
5.0 KiB
Go
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
|
|
}
|