返回 DeepSeek-Reasonix
workspace_changes.go
根目录 / desktop / workspace_changes.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "fmt"
7 "io"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "sort"
12 "strings"
13 "sync"
14 "time"
15
16 "reasonix/internal/control"
17 "reasonix/internal/diff"
18 "reasonix/internal/gitcmd"
19 )
20
21 type gitStatusEntry struct {
22 Path string
23 OldPath string
24 Status string
25 }
26
27 type workspaceChangeAccumulator struct {
28 view WorkspaceChangeView
29 hasSession bool
30 hasGit bool
31 }
32
33 const (
34 workspaceGitBranchCacheTTL = 2 * time.Second
35 // Bound both decoded file contents and rendered patches before they cross
36 // the Wails bridge; generated files must not turn a preview click into OOM.
37 workspaceChangeDetailLimit = 2 * 1024 * 1024
38 )
39
40 type workspaceGitBranchCacheEntry struct {
41 branch string
42 expires time.Time
43 refreshing bool
44 }
45
46 var workspaceGitBranchCache = struct {
47 sync.Mutex
48 entries map[string]workspaceGitBranchCacheEntry
49 }{entries: map[string]workspaceGitBranchCacheEntry{}}
50
51 var workspaceGitBranchForMetaProbe = workspaceGitBranch
52
53 func (a *App) WorkspaceChanges(tabID string) WorkspaceChangesView {
54 out := WorkspaceChangesView{Files: []WorkspaceChangeView{}, GitAvailable: true}
55 tabID = strings.TrimSpace(tabID)
56
57 workspaceRoot, ctrl, ok := a.workspaceChangesTarget(tabID)
58 if !ok {
59 out.GitAvailable = false
60 out.GitErr = fmt.Sprintf("tab %q not found", tabID)
61 return out
62 }
63
64 base, err := workspaceBaseFromRoot(workspaceRoot)
65 if err != nil {
66 out.GitAvailable = false
67 out.GitErr = err.Error()
68 return out
69 }
70
71 out.GitBranch = workspaceGitBranch(base)
72
73 changes := map[string]*workspaceChangeAccumulator{}
74 add := func(path string) *workspaceChangeAccumulator {
75 path = normalizeWorkspaceRelPath(base, path)
76 if path == "" {
77 return nil
78 }
79 if changes[path] == nil {
80 changes[path] = &workspaceChangeAccumulator{view: WorkspaceChangeView{Path: path}}
81 }
82 return changes[path]
83 }
84
85 if ctrl != nil {
86 for _, meta := range ctrl.Checkpoints() {
87 for _, path := range meta.Paths {
88 acc := add(path)
89 if acc == nil {
90 continue
91 }
92 acc.hasSession = true
93 if len(acc.view.Turns) == 0 || acc.view.Turns[len(acc.view.Turns)-1] != meta.Turn {
94 acc.view.Turns = append(acc.view.Turns, meta.Turn)
95 }
96 if meta.Time.UnixMilli() >= acc.view.LatestTime {
97 acc.view.LatestPrompt = meta.Prompt
98 acc.view.LatestTime = meta.Time.UnixMilli()
99 }
100 }
101 }
102 }
103
104 gitEntries, gitErr := workspaceGitStatus(base)
105 if gitErr != nil {
106 out.GitAvailable = false
107 out.GitErr = gitErr.Error()
108 }
109 for _, entry := range gitEntries {
110 acc := add(entry.Path)
111 if acc == nil {
112 continue
113 }
114 acc.hasGit = true
115 acc.view.GitStatus = entry.Status
116 acc.view.OldPath = normalizeWorkspaceRelPath(base, entry.OldPath)
117 }
118
119 out.Files = make([]WorkspaceChangeView, 0, len(changes))
120 for _, acc := range changes {
121 if acc.hasSession {
122 acc.view.Sources = append(acc.view.Sources, "session")
123 // Session-owned files with a recorded preimage can one-click revert
124 // to the first Reasonix touch (not Git HEAD).
125 if ctrl != nil {
126 if state, ok := ctrl.CheckpointFileState(acc.view.Path); ok && state.Owned {
127 acc.view.CanSessionRevert = true
128 }
129 }
130 }
131 if acc.hasGit {
132 acc.view.Sources = append(acc.view.Sources, "git")
133 }
134 out.Files = append(out.Files, acc.view)
135 }
136 sort.Slice(out.Files, func(i, j int) bool {
137 a, b := out.Files[i], out.Files[j]
138 if len(a.Sources) != len(b.Sources) {
139 return len(a.Sources) > len(b.Sources)
140 }
141 return strings.ToLower(a.Path) < strings.ToLower(b.Path)
142 })
143 return out
144 }
145
146 func (a *App) workspaceChangesTarget(tabID string) (string, control.SessionAPI, bool) {
147 a.mu.RLock()
148 defer a.mu.RUnlock()
149 var tab *WorkspaceTab
150 if tabID == "" {
151 tab = a.activeTabLocked()
152 } else {
153 tab = a.tabs[tabID]
154 }
155 if tab == nil {
156 return "", nil, tabID == ""
157 }
158 return tab.WorkspaceRoot, tab.Ctrl, true
159 }
160
161 func (a *App) workspaceBaseForTab(tabID string) (string, error) {
162 tabID = strings.TrimSpace(tabID)
163 workspaceRoot, _, ok := a.workspaceChangesTarget(tabID)
164 if !ok {
165 return "", fmt.Errorf("tab %q not found", tabID)
166 }
167 return workspaceBaseFromRoot(workspaceRoot)
168 }
169
170 // WorkspaceChangeDetail returns the current patch for one file in the
171 // requested tab. Git is authoritative when available because HEAD -> worktree
172 // includes both staged and unstaged edits. Session checkpoints provide a
173 // git-free fallback and cover files edited by Reasonix before Git notices them.
174 func (a *App) WorkspaceChangeDetail(tabID, path string) (WorkspaceChangeDetailView, error) {
175 workspaceRoot, ctrl, ok := a.workspaceChangesTarget(strings.TrimSpace(tabID))
176 if !ok {
177 return WorkspaceChangeDetailView{}, fmt.Errorf("tab %q not found", tabID)
178 }
179 base, err := workspaceBaseFromRoot(workspaceRoot)
180 if err != nil {
181 return WorkspaceChangeDetailView{}, err
182 }
183 rel := normalizeWorkspaceRelPath(base, path)
184 if rel == "" {
185 return WorkspaceChangeDetailView{}, os.ErrInvalid
186 }
187 if _, ok, err := workspacePathForBase(base, filepath.FromSlash(rel)); err != nil || !ok {
188 if err != nil {
189 return WorkspaceChangeDetailView{}, err
190 }
191 return WorkspaceChangeDetailView{}, os.ErrInvalid
192 }
193
194 if detail, found := workspaceGitChangeDetail(base, rel); found {
195 return detail, nil
196 }
197 if ctrl != nil {
198 if state, found := ctrl.CheckpointFileState(rel); found {
199 return workspaceCheckpointChangeDetail(base, rel, state.Content)
200 }
201 }
202 return WorkspaceChangeDetailView{}, nil
203 }
204
205 func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool) {
206 entries, err := workspaceGitStatus(base)
207 if err != nil {
208 return WorkspaceChangeDetailView{}, false
209 }
210 var entry *gitStatusEntry
211 for i := range entries {
212 if entries[i].Path == rel {
213 entry = &entries[i]
214 break
215 }
216 }
217 if entry == nil {
218 return WorkspaceChangeDetailView{}, false
219 }
220
221 // Untracked files are omitted by git diff. In an unborn repository HEAD is
222 // absent as well, so synthesize the same create/delete patch from disk.
223 if entry.Status == "??" || !workspaceGitHasHead(base) {
224 detail, err := workspaceCheckpointChangeDetail(base, rel, nil)
225 if err != nil {
226 return WorkspaceChangeDetailView{}, false
227 }
228 detail.Source = "git"
229 return detail, true
230 }
231
232 args := []string{"-C", base, "diff", "--no-ext-diff", "--no-textconv", "--relative", "HEAD", "--", filepath.FromSlash(rel)}
233 if entry.OldPath != "" && entry.OldPath != rel {
234 args = append(args, filepath.FromSlash(entry.OldPath))
235 }
236 raw, truncated, err := workspaceGitDiffOutput(args...)
237 if err != nil {
238 return WorkspaceChangeDetailView{}, false
239 }
240 if truncated {
241 return WorkspaceChangeDetailView{Source: "git", Truncated: true}, true
242 }
243 patch := strings.TrimSpace(string(raw))
244 if patch == "" {
245 return WorkspaceChangeDetailView{}, false
246 }
247 added, removed := tallyUnifiedPatch(patch)
248 binary := strings.Contains(patch, "Binary files ") || strings.Contains(patch, "GIT binary patch")
249 return WorkspaceChangeDetailView{Diff: &patch, Source: "git", Added: added, Removed: removed, Binary: binary}, true
250 }
251
252 func workspaceGitHasHead(base string) bool {
253 return workspaceGit("-C", base, "rev-parse", "--verify", "HEAD").Run() == nil
254 }
255
256 func workspaceGitDiffOutput(args ...string) ([]byte, bool, error) {
257 cmd := workspaceGit(args...)
258 stdout, err := cmd.StdoutPipe()
259 if err != nil {
260 return nil, false, err
261 }
262 cmd.Stderr = io.Discard
263 if err := cmd.Start(); err != nil {
264 _ = stdout.Close()
265 return nil, false, err
266 }
267 raw, readErr := io.ReadAll(io.LimitReader(stdout, workspaceChangeDetailLimit+1))
268 if readErr != nil {
269 _ = stdout.Close()
270 if cmd.Process != nil {
271 _ = cmd.Process.Kill()
272 }
273 _ = cmd.Wait()
274 return nil, false, readErr
275 }
276 if len(raw) > workspaceChangeDetailLimit {
277 _ = stdout.Close()
278 if cmd.Process != nil {
279 _ = cmd.Process.Kill()
280 }
281 _ = cmd.Wait()
282 return nil, true, nil
283 }
284 waitErr := cmd.Wait()
285 if waitErr != nil {
286 return nil, false, waitErr
287 }
288 return raw, false, nil
289 }
290
291 func workspaceCheckpointChangeDetail(base, rel string, old *string) (WorkspaceChangeDetailView, error) {
292 path, ok, err := workspacePathForBase(base, filepath.FromSlash(rel))
293 if err != nil || !ok {
294 return WorkspaceChangeDetailView{}, err
295 }
296 oldText := ""
297 if old != nil {
298 if len(*old) > workspaceChangeDetailLimit {
299 return WorkspaceChangeDetailView{Source: "session", Truncated: true}, nil
300 }
301 oldText = *old
302 }
303 newText, exists, truncated, err := workspaceCurrentText(path)
304 if err != nil {
305 return WorkspaceChangeDetailView{}, err
306 }
307 if truncated {
308 return WorkspaceChangeDetailView{Source: "session", Truncated: true}, nil
309 }
310 kind := diff.Modify
311 if old == nil {
312 kind = diff.Create
313 } else if !exists {
314 kind = diff.Delete
315 }
316 change := diff.Build(rel, oldText, newText, kind)
317 if len(change.Diff) > workspaceChangeDetailLimit {
318 return WorkspaceChangeDetailView{Source: "session", Truncated: true}, nil
319 }
320 if change.Diff == "" && !change.Binary {
321 return WorkspaceChangeDetailView{Source: "session"}, nil
322 }
323 patch := change.Diff
324 return WorkspaceChangeDetailView{
325 Diff: &patch,
326 Source: "session",
327 Added: change.Added,
328 Removed: change.Removed,
329 Binary: change.Binary,
330 }, nil
331 }
332
333 func workspaceCurrentText(path string) (string, bool, bool, error) {
334 info, err := os.Lstat(path)
335 if os.IsNotExist(err) {
336 return "", false, false, nil
337 }
338 if err != nil {
339 return "", false, false, err
340 }
341 if info.Mode()&os.ModeSymlink != 0 {
342 target, err := os.Readlink(path)
343 return target, true, false, err
344 }
345 if !info.Mode().IsRegular() {
346 return "", true, false, fmt.Errorf("workspace change path %q is not a regular file", path)
347 }
348 raw, truncated, err := readFileUTF8Limit(path, workspaceChangeDetailLimit)
349 return string(raw), true, truncated, err
350 }
351
352 func tallyUnifiedPatch(patch string) (added, removed int) {
353 inHunk := false
354 for _, line := range strings.Split(patch, "\n") {
355 switch {
356 case strings.HasPrefix(line, "@@"):
357 inHunk = true
358 case strings.HasPrefix(line, "diff --git "):
359 inHunk = false
360 case inHunk && strings.HasPrefix(line, "+"):
361 added++
362 case inHunk && strings.HasPrefix(line, "-"):
363 removed++
364 }
365 }
366 return added, removed
367 }
368
369 // workspaceGit builds a console-hidden git probe. gitcmd supplies the shared
370 // invocation baseline: CREATE_NO_WINDOW so git's own children inherit the
371 // invisible console, and the config overrides that keep a probe from spawning a
372 // background daemon that opens a console of its own (#3906).
373 func workspaceGit(args ...string) *exec.Cmd {
374 return workspaceGitCommand(context.Background(), args...)
375 }
376
377 func workspaceGitCommand(ctx context.Context, args ...string) *exec.Cmd {
378 return gitcmd.Command(ctx, "", args...)
379 }
380
381 func workspaceGitOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) {
382 ctx, cancel := context.WithTimeout(context.Background(), timeout)
383 defer cancel()
384 return workspaceGitCommand(ctx, args...).Output()
385 }
386
387 func workspaceGitStatus(base string) ([]gitStatusEntry, error) {
388 // Git's porcelain paths are repository-relative even when -C points at a
389 // subdirectory. Derive the textual repository prefix from Git itself instead
390 // of comparing absolute paths: Windows may spell the same directory once as
391 // an 8.3 path and once as a long path, which makes filepath.Rel reject every
392 // otherwise valid status entry.
393 prefixCmd := workspaceGit("-C", base, "rev-parse", "--show-prefix")
394 prefixRaw, err := prefixCmd.Output()
395 if err != nil {
396 return nil, err
397 }
398 prefix := strings.TrimSpace(string(prefixRaw))
399 cmd := workspaceGit("-C", base, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".")
400 raw, err := cmd.Output()
401 if err != nil {
402 return nil, err
403 }
404 entries := parseGitStatusPorcelainZ(raw)
405 out := make([]gitStatusEntry, 0, len(entries))
406 for _, entry := range entries {
407 entry.Path = workspaceRelPathFromGitPrefix(base, prefix, entry.Path)
408 if entry.Path == "" {
409 continue
410 }
411 entry.OldPath = workspaceRelPathFromGitPrefix(base, prefix, entry.OldPath)
412 out = append(out, entry)
413 }
414 return out, nil
415 }
416
417 func parseGitStatusPorcelainZ(raw []byte) []gitStatusEntry {
418 parts := bytes.Split(raw, []byte{0})
419 out := make([]gitStatusEntry, 0, len(parts))
420 for i := 0; i < len(parts); i++ {
421 part := parts[i]
422 if len(part) < 4 {
423 continue
424 }
425 status := string(part[:2])
426 path := string(part[3:])
427 entry := gitStatusEntry{Path: path, Status: strings.TrimSpace(status)}
428 if strings.ContainsAny(status, "RC") && i+1 < len(parts) {
429 i++
430 entry.OldPath = string(parts[i])
431 }
432 out = append(out, entry)
433 }
434 return out
435 }
436
437 func normalizeWorkspaceRelPath(base, path string) string {
438 path = strings.TrimSpace(path)
439 if path == "" {
440 return ""
441 }
442 if filepath.IsAbs(path) {
443 if rel, err := filepath.Rel(base, path); err == nil {
444 path = rel
445 }
446 }
447 path = filepath.Clean(path)
448 if path == "." || path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator)) {
449 return ""
450 }
451 return filepath.ToSlash(path)
452 }
453
454 func workspaceRelPathFromGitPrefix(base, prefix, path string) string {
455 path = filepath.ToSlash(strings.TrimSpace(path))
456 prefix = filepath.ToSlash(strings.TrimSpace(prefix))
457 if path == "" {
458 return ""
459 }
460 if prefix != "" {
461 if !strings.HasPrefix(path, prefix) {
462 return ""
463 }
464 path = strings.TrimPrefix(path, prefix)
465 }
466 return normalizeWorkspaceRelPath(base, filepath.FromSlash(path))
467 }
468
469 // workspaceGitBranchForMeta is the cached variant used by high-frequency UI
470 // metadata refreshes. It never waits for git on the caller path: stale branch
471 // metadata is less harmful than blocking tab activation or hydration. Workflows
472 // that need an immediate git read, such as WorkspaceChanges, should call
473 // workspaceGitBranch directly.
474 func workspaceGitBranchForMeta(base string) string {
475 key := filepath.Clean(base)
476 now := time.Now()
477
478 workspaceGitBranchCache.Lock()
479 if cached, ok := workspaceGitBranchCache.entries[key]; ok {
480 branch := cached.branch
481 if now.Before(cached.expires) || cached.refreshing {
482 workspaceGitBranchCache.Unlock()
483 return branch
484 }
485 cached.refreshing = true
486 workspaceGitBranchCache.entries[key] = cached
487 workspaceGitBranchCache.Unlock()
488 go refreshWorkspaceGitBranchForMeta(key, base)
489 return branch
490 }
491
492 workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{
493 expires: now.Add(workspaceGitBranchCacheTTL),
494 refreshing: true,
495 }
496 workspaceGitBranchCache.Unlock()
497
498 go refreshWorkspaceGitBranchForMeta(key, base)
499 return ""
500 }
501
502 func refreshWorkspaceGitBranchForMeta(key, base string) {
503 branch := ""
504 // Store via defer so the refreshing flag is always cleared, even when the
505 // probe panics or exits the goroutine early; otherwise the entry would stay
506 // marked refreshing forever and never update again.
507 defer func() {
508 storeNow := time.Now()
509 workspaceGitBranchCache.Lock()
510 if len(workspaceGitBranchCache.entries) > 256 {
511 for k, cached := range workspaceGitBranchCache.entries {
512 if storeNow.After(cached.expires) {
513 delete(workspaceGitBranchCache.entries, k)
514 }
515 }
516 }
517 workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{branch: branch, expires: storeNow.Add(workspaceGitBranchCacheTTL)}
518 workspaceGitBranchCache.Unlock()
519 }()
520
521 branch = workspaceGitBranchForMetaProbe(base)
522 }
523
524 // workspaceGitBranch returns the current git branch name for the repo rooted
525 // at base, or an empty string when base is not inside a git repository or when
526 // git is unavailable.
527 func workspaceGitBranch(base string) string {
528 raw, err := workspaceGitOutputWithTimeout(2*time.Second, "-C", base, "branch", "--show-current")
529 if err != nil {
530 return ""
531 }
532 if branch := strings.TrimSpace(string(raw)); branch != "" {
533 return branch
534 }
535
536 raw, err = workspaceGitOutputWithTimeout(2*time.Second, "-C", base, "rev-parse", "--short", "HEAD")
537 if err != nil {
538 return ""
539 }
540 short := strings.TrimSpace(string(raw))
541 if short == "" {
542 return ""
543 }
544 return "@" + short
545 }
546
547 // GitBranches returns all local git branches for the active workspace's repo.
548 func (a *App) GitBranches() ([]string, error) {
549 base, err := a.activeWorkspaceBase()
550 if err != nil {
551 return nil, err
552 }
553 cmd := workspaceGit("-C", base, "branch", "--format=%(refname:short)")
554 raw, err := cmd.Output()
555 if err != nil {
556 return nil, err
557 }
558 branches := append([]string{}, strings.FieldsFunc(strings.TrimSpace(string(raw)), func(r rune) bool { return r == '\n' })...)
559 return branches, nil
560 }
561
562 // GitCheckout switches the active workspace's git branch and returns the
563 // current branch name, or an error when git is unavailable.
564 func (a *App) GitCheckout(branch string) error {
565 base, err := a.activeWorkspaceBase()
566 if err != nil {
567 return err
568 }
569 cmd := workspaceGit("-C", base, "checkout", branch)
570 out, err := cmd.CombinedOutput()
571 if err != nil {
572 if len(out) > 0 {
573 return fmt.Errorf("git checkout: %s", strings.TrimSpace(string(out)))
574 }
575 return err
576 }
577 return nil
578 }
579
580 type GitCommitView struct {
581 Hash string `json:"hash"`
582 Author string `json:"author"`
583 Date string `json:"date"`
584 Message string `json:"message"`
585 }
586
587 type GitCommitDetailView struct {
588 Diff *string `json:"diff,omitempty"`
589 Files []string `json:"files,omitempty"`
590 }
591
592 func (a *App) WorkspaceGitHistory(tabID string, path string) ([]GitCommitView, error) {
593 base, err := a.workspaceBaseForTab(tabID)
594 if err != nil {
595 return nil, err
596 }
597
598 args := []string{"-C", base, "log", "--pretty=format:%H%x00%an%x00%ad%x00%s", "-z", "-n", "100"}
599 if path != "" {
600 args = append(args, "--", path)
601 }
602
603 cmd := workspaceGit(args...)
604 raw, err := cmd.Output()
605 if err != nil {
606 return nil, err
607 }
608
609 parts := bytes.Split(raw, []byte{0})
610 out := []GitCommitView{}
611 // 4 parts per commit: hash, author, date, message
612 for i := 0; i+3 < len(parts); i += 4 {
613 out = append(out, GitCommitView{
614 Hash: string(parts[i]),
615 Author: string(parts[i+1]),
616 Date: string(parts[i+2]),
617 Message: string(parts[i+3]),
618 })
619 }
620 return out, nil
621 }
622
623 func (a *App) WorkspaceGitCommitDetail(tabID string, hash string, path string) (GitCommitDetailView, error) {
624 base, err := a.workspaceBaseForTab(tabID)
625 if err != nil {
626 return GitCommitDetailView{}, err
627 }
628
629 if path != "" {
630 // Single file diff
631 cmd := workspaceGit("-C", base, "show", "--relative", "--pretty=format:", "--patch", hash, "--", path)
632 raw, err := cmd.Output()
633 if err != nil {
634 return GitCommitDetailView{}, err
635 }
636 diffStr := strings.TrimSpace(string(raw))
637 return GitCommitDetailView{Diff: &diffStr}, nil
638 }
639
640 // Project level: list of files changed
641 cmd := workspaceGit("-C", base, "diff-tree", "--relative", "--no-commit-id", "--name-only", "-r", hash)
642 raw, err := cmd.Output()
643 if err != nil {
644 return GitCommitDetailView{}, err
645 }
646
647 lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
648 var files []string
649 for _, line := range lines {
650 if line != "" {
651 files = append(files, line)
652 }
653 }
654 return GitCommitDetailView{Files: files}, nil
655 }
656
656 lines GO