Files
2026-07-13 13:00:08 +08:00

437 lines
12 KiB
Go

package main
import (
"bytes"
"context"
"fmt"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"reasonix/internal/control"
"reasonix/internal/proc"
)
type gitStatusEntry struct {
Path string
OldPath string
Status string
}
type workspaceChangeAccumulator struct {
view WorkspaceChangeView
hasSession bool
hasGit bool
}
const workspaceGitBranchCacheTTL = 2 * time.Second
type workspaceGitBranchCacheEntry struct {
branch string
expires time.Time
refreshing bool
}
var workspaceGitBranchCache = struct {
sync.Mutex
entries map[string]workspaceGitBranchCacheEntry
}{entries: map[string]workspaceGitBranchCacheEntry{}}
var workspaceGitBranchForMetaProbe = workspaceGitBranch
func (a *App) WorkspaceChanges(tabID string) WorkspaceChangesView {
out := WorkspaceChangesView{Files: []WorkspaceChangeView{}, GitAvailable: true}
tabID = strings.TrimSpace(tabID)
workspaceRoot, ctrl, ok := a.workspaceChangesTarget(tabID)
if !ok {
out.GitAvailable = false
out.GitErr = fmt.Sprintf("tab %q not found", tabID)
return out
}
base, err := workspaceBaseFromRoot(workspaceRoot)
if err != nil {
out.GitAvailable = false
out.GitErr = err.Error()
return out
}
out.GitBranch = workspaceGitBranch(base)
changes := map[string]*workspaceChangeAccumulator{}
add := func(path string) *workspaceChangeAccumulator {
path = normalizeWorkspaceRelPath(base, path)
if path == "" {
return nil
}
if changes[path] == nil {
changes[path] = &workspaceChangeAccumulator{view: WorkspaceChangeView{Path: path}}
}
return changes[path]
}
if ctrl != nil {
for _, meta := range ctrl.Checkpoints() {
for _, path := range meta.Paths {
acc := add(path)
if acc == nil {
continue
}
acc.hasSession = true
if len(acc.view.Turns) == 0 || acc.view.Turns[len(acc.view.Turns)-1] != meta.Turn {
acc.view.Turns = append(acc.view.Turns, meta.Turn)
}
if meta.Time.UnixMilli() >= acc.view.LatestTime {
acc.view.LatestPrompt = meta.Prompt
acc.view.LatestTime = meta.Time.UnixMilli()
}
}
}
}
gitEntries, gitErr := workspaceGitStatus(base)
if gitErr != nil {
out.GitAvailable = false
out.GitErr = gitErr.Error()
}
for _, entry := range gitEntries {
acc := add(entry.Path)
if acc == nil {
continue
}
acc.hasGit = true
acc.view.GitStatus = entry.Status
acc.view.OldPath = normalizeWorkspaceRelPath(base, entry.OldPath)
}
out.Files = make([]WorkspaceChangeView, 0, len(changes))
for _, acc := range changes {
if acc.hasSession {
acc.view.Sources = append(acc.view.Sources, "session")
}
if acc.hasGit {
acc.view.Sources = append(acc.view.Sources, "git")
}
out.Files = append(out.Files, acc.view)
}
sort.Slice(out.Files, func(i, j int) bool {
a, b := out.Files[i], out.Files[j]
if len(a.Sources) != len(b.Sources) {
return len(a.Sources) > len(b.Sources)
}
return strings.ToLower(a.Path) < strings.ToLower(b.Path)
})
return out
}
func (a *App) workspaceChangesTarget(tabID string) (string, control.SessionAPI, bool) {
a.mu.RLock()
defer a.mu.RUnlock()
var tab *WorkspaceTab
if tabID == "" {
tab = a.activeTabLocked()
} else {
tab = a.tabs[tabID]
}
if tab == nil {
return "", nil, tabID == ""
}
return tab.WorkspaceRoot, tab.Ctrl, true
}
func (a *App) workspaceBaseForTab(tabID string) (string, error) {
tabID = strings.TrimSpace(tabID)
workspaceRoot, _, ok := a.workspaceChangesTarget(tabID)
if !ok {
return "", fmt.Errorf("tab %q not found", tabID)
}
return workspaceBaseFromRoot(workspaceRoot)
}
// workspaceGit builds a console-hidden git probe: CREATE_NO_WINDOW so git's own
// children inherit the invisible console, fsmonitor/auto-maintenance off so a
// probe never spawns a background daemon that opens a console of its own (#3906).
func workspaceGit(args ...string) *exec.Cmd {
return workspaceGitCommand(context.Background(), args...)
}
func workspaceGitCommand(ctx context.Context, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", append([]string{"-c", "core.fsmonitor=false", "-c", "maintenance.auto=false"}, args...)...)
proc.HideWindow(cmd)
return cmd
}
func workspaceGitOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return workspaceGitCommand(ctx, args...).Output()
}
func workspaceGitStatus(base string) ([]gitStatusEntry, error) {
cmd := workspaceGit("-C", base, "status", "--porcelain=v1", "-z", "--untracked-files=all")
raw, err := cmd.Output()
if err != nil {
return nil, err
}
entries := parseGitStatusPorcelainZ(raw)
topCmd := workspaceGit("-C", base, "rev-parse", "--show-toplevel")
topRaw, err := topCmd.Output()
if err != nil {
return nil, err
}
repoRoot := strings.TrimSpace(string(topRaw))
if repoRoot == "" {
return entries, nil
}
out := make([]gitStatusEntry, 0, len(entries))
for _, entry := range entries {
entry.Path = workspaceRelPathFromGitStatus(repoRoot, base, entry.Path)
if entry.Path == "" {
continue
}
entry.OldPath = workspaceRelPathFromGitStatus(repoRoot, base, entry.OldPath)
out = append(out, entry)
}
return out, nil
}
func parseGitStatusPorcelainZ(raw []byte) []gitStatusEntry {
parts := bytes.Split(raw, []byte{0})
out := make([]gitStatusEntry, 0, len(parts))
for i := 0; i < len(parts); i++ {
part := parts[i]
if len(part) < 4 {
continue
}
status := string(part[:2])
path := string(part[3:])
entry := gitStatusEntry{Path: path, Status: strings.TrimSpace(status)}
if strings.ContainsAny(status, "RC") && i+1 < len(parts) {
i++
entry.OldPath = string(parts[i])
}
out = append(out, entry)
}
return out
}
func normalizeWorkspaceRelPath(base, path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if filepath.IsAbs(path) {
if rel, err := filepath.Rel(base, path); err == nil {
path = rel
}
}
path = filepath.Clean(path)
if path == "." || path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator)) {
return ""
}
return filepath.ToSlash(path)
}
func workspaceRelPathFromGitStatus(repoRoot, base, path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if !filepath.IsAbs(path) {
path = filepath.Join(repoRoot, filepath.FromSlash(path))
}
return normalizeWorkspaceRelPath(base, path)
}
// workspaceGitBranchForMeta is the cached variant used by high-frequency UI
// metadata refreshes. It never waits for git on the caller path: stale branch
// metadata is less harmful than blocking tab activation or hydration. Workflows
// that need an immediate git read, such as WorkspaceChanges, should call
// workspaceGitBranch directly.
func workspaceGitBranchForMeta(base string) string {
key := filepath.Clean(base)
now := time.Now()
workspaceGitBranchCache.Lock()
if cached, ok := workspaceGitBranchCache.entries[key]; ok {
branch := cached.branch
if now.Before(cached.expires) || cached.refreshing {
workspaceGitBranchCache.Unlock()
return branch
}
cached.refreshing = true
workspaceGitBranchCache.entries[key] = cached
workspaceGitBranchCache.Unlock()
go refreshWorkspaceGitBranchForMeta(key, base)
return branch
}
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{
expires: now.Add(workspaceGitBranchCacheTTL),
refreshing: true,
}
workspaceGitBranchCache.Unlock()
go refreshWorkspaceGitBranchForMeta(key, base)
return ""
}
func refreshWorkspaceGitBranchForMeta(key, base string) {
branch := ""
// Store via defer so the refreshing flag is always cleared, even when the
// probe panics or exits the goroutine early; otherwise the entry would stay
// marked refreshing forever and never update again.
defer func() {
storeNow := time.Now()
workspaceGitBranchCache.Lock()
if len(workspaceGitBranchCache.entries) > 256 {
for k, cached := range workspaceGitBranchCache.entries {
if storeNow.After(cached.expires) {
delete(workspaceGitBranchCache.entries, k)
}
}
}
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{branch: branch, expires: storeNow.Add(workspaceGitBranchCacheTTL)}
workspaceGitBranchCache.Unlock()
}()
branch = workspaceGitBranchForMetaProbe(base)
}
// workspaceGitBranch returns the current git branch name for the repo rooted
// at base, or an empty string when base is not inside a git repository or when
// git is unavailable.
func workspaceGitBranch(base string) string {
raw, err := workspaceGitOutputWithTimeout(2*time.Second, "-C", base, "branch", "--show-current")
if err != nil {
return ""
}
if branch := strings.TrimSpace(string(raw)); branch != "" {
return branch
}
raw, err = workspaceGitOutputWithTimeout(2*time.Second, "-C", base, "rev-parse", "--short", "HEAD")
if err != nil {
return ""
}
short := strings.TrimSpace(string(raw))
if short == "" {
return ""
}
return "@" + short
}
// GitBranches returns all local git branches for the active workspace's repo.
func (a *App) GitBranches() ([]string, error) {
base, err := a.activeWorkspaceBase()
if err != nil {
return nil, err
}
cmd := workspaceGit("-C", base, "branch", "--format=%(refname:short)")
raw, err := cmd.Output()
if err != nil {
return nil, err
}
branches := append([]string{}, strings.FieldsFunc(strings.TrimSpace(string(raw)), func(r rune) bool { return r == '\n' })...)
return branches, nil
}
// GitCheckout switches the active workspace's git branch and returns the
// current branch name, or an error when git is unavailable.
func (a *App) GitCheckout(branch string) error {
base, err := a.activeWorkspaceBase()
if err != nil {
return err
}
cmd := workspaceGit("-C", base, "checkout", branch)
out, err := cmd.CombinedOutput()
if err != nil {
if len(out) > 0 {
return fmt.Errorf("git checkout: %s", strings.TrimSpace(string(out)))
}
return err
}
return nil
}
type GitCommitView struct {
Hash string `json:"hash"`
Author string `json:"author"`
Date string `json:"date"`
Message string `json:"message"`
}
type GitCommitDetailView struct {
Diff *string `json:"diff,omitempty"`
Files []string `json:"files,omitempty"`
}
func (a *App) WorkspaceGitHistory(tabID string, path string) ([]GitCommitView, error) {
base, err := a.workspaceBaseForTab(tabID)
if err != nil {
return nil, err
}
args := []string{"-C", base, "log", "--pretty=format:%H%x00%an%x00%ad%x00%s", "-z", "-n", "100"}
if path != "" {
args = append(args, "--", path)
}
cmd := workspaceGit(args...)
raw, err := cmd.Output()
if err != nil {
return nil, err
}
parts := bytes.Split(raw, []byte{0})
out := []GitCommitView{}
// 4 parts per commit: hash, author, date, message
for i := 0; i+3 < len(parts); i += 4 {
out = append(out, GitCommitView{
Hash: string(parts[i]),
Author: string(parts[i+1]),
Date: string(parts[i+2]),
Message: string(parts[i+3]),
})
}
return out, nil
}
func (a *App) WorkspaceGitCommitDetail(tabID string, hash string, path string) (GitCommitDetailView, error) {
base, err := a.workspaceBaseForTab(tabID)
if err != nil {
return GitCommitDetailView{}, err
}
if path != "" {
// Single file diff
cmd := workspaceGit("-C", base, "show", "--relative", "--pretty=format:", "--patch", hash, "--", path)
raw, err := cmd.Output()
if err != nil {
return GitCommitDetailView{}, err
}
diffStr := strings.TrimSpace(string(raw))
return GitCommitDetailView{Diff: &diffStr}, nil
}
// Project level: list of files changed
cmd := workspaceGit("-C", base, "diff-tree", "--relative", "--no-commit-id", "--name-only", "-r", hash)
raw, err := cmd.Output()
if err != nil {
return GitCommitDetailView{}, err
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
var files []string
for _, line := range lines {
if line != "" {
files = append(files, line)
}
}
return GitCommitDetailView{Files: files}, nil
}