437 lines
12 KiB
Go
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
|
|
}
|