Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions internal/app/app_input_messages_center.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func (a *App) handleOpenDiff(msg messages.OpenDiff) tea.Cmd {
logging.Info("Opening diff: change=%v", msg.Change)
newCenter, cmd := a.center.Update(msg)
a.center = newCenter
return cmd
return tea.Batch(cmd, a.focusPane(messages.PaneCenter))
}

// handleLaunchAgent handles the LaunchAgent message.
Expand All @@ -27,6 +27,9 @@ func (a *App) handleLaunchAgent(msg messages.LaunchAgent) tea.Cmd {
func (a *App) handleTabCreated(msg messages.TabCreated) tea.Cmd {
logging.Info("Tab created: %s", msg.Name)
cmd := a.center.StartPTYReaders()
a.focusPane(messages.PaneCenter)
return cmd
if a.center != nil && a.center.HasDiffViewer() {
a.setFocusedPane(messages.PaneCenter)
return cmd
}
return tea.Batch(cmd, a.focusPane(messages.PaneCenter))
}
92 changes: 92 additions & 0 deletions internal/app/app_input_open_diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package app

import (
"testing"

tea "charm.land/bubbletea/v2"

"github.com/andyrewlee/amux/internal/config"
"github.com/andyrewlee/amux/internal/data"
"github.com/andyrewlee/amux/internal/git"
"github.com/andyrewlee/amux/internal/messages"
"github.com/andyrewlee/amux/internal/ui/center"
"github.com/andyrewlee/amux/internal/ui/diff"
)

func TestOpenDiff_ReusesExistingChangedFileTab(t *testing.T) {
ws := data.NewWorkspace("feature", "feature", "main", "/repo", "/repo")
wsID := string(ws.ID())

centerModel := center.New(&config.Config{})
centerModel.SetWorkspace(ws)
centerModel.AddTab(&center.Tab{
ID: center.TabID("tab-chat"),
Name: "claude",
Assistant: "claude",
Workspace: ws,
Running: true,
})
centerModel.AddTab(&center.Tab{
ID: center.TabID("tab-diff"),
Name: "Diff: main.go",
Assistant: "diff",
Workspace: ws,
DiffViewer: diff.New(ws, &git.Change{Path: "main.go", Kind: git.ChangeModified}, git.DiffModeUnstaged, 80, 24),
})
centerModel.SelectTab(0)

app := &App{
activeWorkspace: ws,
center: centerModel,
focusedPane: messages.PaneSidebar,
}

_, cmd := app.Update(messages.OpenDiff{
Change: &git.Change{Path: "./main.go", Kind: git.ChangeModified},
Mode: git.DiffModeUnstaged,
Workspace: ws,
})
if cmd == nil {
t.Fatal("expected command when opening an already-open changed file")
}

tabs, activeIdx := app.center.GetTabsInfoForWorkspace(wsID)
if len(tabs) != 2 {
t.Fatalf("expected changed-file click to reuse existing diff tab, got %d tabs", len(tabs))
}
if activeIdx != 1 {
t.Fatalf("expected existing diff tab to become active immediately, got index %d", activeIdx)
}
if app.focusedPane != messages.PaneCenter {
t.Fatalf("expected center focus after opening diff, got %v", app.focusedPane)
}

msg := cmd()
switch typed := msg.(type) {
case tea.BatchMsg:
for _, subcmd := range typed {
if subcmd == nil {
continue
}
submsg := subcmd()
if submsg == nil {
continue
}
if _, followup := app.Update(submsg); followup != nil {
_ = followup()
}
}
default:
if _, followup := app.Update(typed); followup != nil {
_ = followup()
}
}

tabs, activeIdx = app.center.GetTabsInfoForWorkspace(wsID)
if len(tabs) != 2 {
t.Fatalf("expected no duplicate diff tab after follow-up updates, got %d tabs", len(tabs))
}
if activeIdx != 1 {
t.Fatalf("expected reused diff tab to stay active, got index %d", activeIdx)
}
}
156 changes: 156 additions & 0 deletions internal/ui/center/model_tabs_diff_reuse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package center

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

tea "charm.land/bubbletea/v2"

"github.com/andyrewlee/amux/internal/data"
"github.com/andyrewlee/amux/internal/git"
"github.com/andyrewlee/amux/internal/messages"
"github.com/andyrewlee/amux/internal/ui/diff"
)

func TestCreateDiffTab_ReusesExistingTabForSamePathAndMode(t *testing.T) {
m := newTestModel()
ws := newTestWorkspace("ws", "/repo/ws")
wsID := string(ws.ID())
m.SetWorkspace(ws)

m.tabsByWorkspace[wsID] = []*Tab{
{
ID: TabID("tab-chat"),
Name: "claude",
Assistant: "claude",
Workspace: ws,
Running: true,
},
{
ID: TabID("tab-diff"),
Name: "Diff: main.go",
Assistant: "diff",
Workspace: ws,
DiffViewer: diff.New(ws, &git.Change{Path: "main.go", Kind: git.ChangeModified}, git.DiffModeUnstaged, 80, 24),
},
}
m.activeTabByWorkspace[wsID] = 0

cmd := m.createDiffTab(&git.Change{Path: "./main.go", Kind: git.ChangeModified}, git.DiffModeUnstaged, ws)
if cmd == nil {
t.Fatal("expected reuse command for existing diff tab")
}
if got := len(m.tabsByWorkspace[wsID]); got != 2 {
t.Fatalf("expected existing diff tab reuse, got %d tabs", got)
}
if got := m.activeTabByWorkspace[wsID]; got != 1 {
t.Fatalf("expected existing diff tab to become active, got index %d", got)
}

msg := cmd()
sawSelection := false
sawReload := false

switch typed := msg.(type) {
case tea.BatchMsg:
for _, subcmd := range typed {
if subcmd == nil {
continue
}
switch submsg := subcmd().(type) {
case messages.TabSelectionChanged:
sawSelection = true
if submsg.WorkspaceID != wsID || submsg.ActiveIndex != 1 {
t.Fatalf("unexpected selection payload: %+v", submsg)
}
default:
if strings.HasSuffix(fmt.Sprintf("%T", submsg), ".diffLoaded") {
sawReload = true
}
}
}
default:
t.Fatalf("expected batched reuse command, got %T", typed)
}

if !sawSelection {
t.Fatal("expected tab selection change for reused diff tab")
}
if !sawReload {
t.Fatal("expected reused diff tab to reload its diff")
}
}

func TestReuseDiffTab_RefreshesChangeKindBeforeReload(t *testing.T) {
repo := t.TempDir()
mustRunGit(t, repo, "init", "-b", "main")
mustRunGit(t, repo, "config", "user.name", "Test")
mustRunGit(t, repo, "config", "user.email", "test@example.com")

filePath := filepath.Join(repo, "main.go")
if err := os.WriteFile(filePath, []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
t.Fatalf("write untracked file: %v", err)
}

ws := data.NewWorkspace("ws", "ws", "main", repo, repo)
wsID := string(ws.ID())
m := newTestModel()
m.SetWorkspace(ws)

dv := diff.New(ws, &git.Change{Path: "main.go", Kind: git.ChangeUntracked}, git.DiffModeUnstaged, 80, 24)
m.tabsByWorkspace[wsID] = []*Tab{
{
ID: TabID("tab-diff"),
Name: "Diff: main.go",
Assistant: "diff",
Workspace: ws,
DiffViewer: dv,
},
}
m.activeTabByWorkspace[wsID] = 0

mustRunGit(t, repo, "add", "main.go")
mustRunGit(t, repo, "commit", "-m", "track main.go")
if err := os.WriteFile(filePath, []byte("package main\n\nfunc main() { println(\"hi\") }\n"), 0o644); err != nil {
t.Fatalf("write tracked modification: %v", err)
}

cmd := m.createDiffTab(&git.Change{Path: "main.go", Kind: git.ChangeModified}, git.DiffModeUnstaged, ws)
if cmd == nil {
t.Fatal("expected reuse command for tracked modification")
}

msg := cmd()
switch typed := msg.(type) {
case tea.BatchMsg:
for _, subcmd := range typed {
if subcmd == nil {
continue
}
submsg := subcmd()
updatedDV, _ := dv.Update(submsg)
dv = updatedDV
}
default:
updatedDV, _ := dv.Update(typed)
dv = updatedDV
}

rendered := dv.View()
if strings.Contains(rendered, "/dev/null") {
t.Fatalf("expected tracked diff reload, got stale untracked diff:\n%s", rendered)
}
if !strings.Contains(rendered, "--- a/main.go") {
t.Fatalf("expected tracked diff header after reuse, got:\n%s", rendered)
}
}

func mustRunGit(t *testing.T, dir string, args ...string) {
t.Helper()
if _, err := git.RunGit(dir, args...); err != nil {
t.Fatalf("git %s failed: %v", strings.Join(args, " "), err)
}
}
46 changes: 46 additions & 0 deletions internal/ui/center/model_tabs_viewer.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,47 @@ func (m *Model) createVimTab(filePath string, ws *data.Workspace) tea.Cmd {
}
}

func (m *Model) findOpenDiffTab(ws *data.Workspace, changePath string, mode git.DiffMode) (int, *Tab) {
if ws == nil {
return -1, nil
}
wsID := string(ws.ID())
for idx, tab := range m.tabsByWorkspace[wsID] {
if tab == nil || tab.isClosed() {
continue
}
tab.mu.Lock()
dv := tab.DiffViewer
tab.mu.Unlock()
if dv != nil && dv.MatchesSource(changePath, mode) {
return idx, tab
}
}
return -1, nil
}

func (m *Model) reuseDiffTab(ws *data.Workspace, idx int, tab *Tab, change *git.Change, mode git.DiffMode) tea.Cmd {
if ws == nil || tab == nil {
return nil
}
wsID := string(ws.ID())
activeChanged := m.activeTabByWorkspace[wsID] != idx
m.setActiveTabIdxForWorkspace(wsID, idx)

var cmds []tea.Cmd
tab.mu.Lock()
dv := tab.DiffViewer
tab.mu.Unlock()
if dv != nil {
dv.ResetSource(ws, change, mode)
cmds = append(cmds, dv.Init())
}
if m.workspaceID() == wsID {
cmds = append(cmds, m.tabSelectionChangedCmd(activeChanged))
}
return common.SafeBatch(cmds...)
}

// createDiffTab creates a new native diff viewer tab (no PTY)
func (m *Model) createDiffTab(change *git.Change, mode git.DiffMode, ws *data.Workspace) tea.Cmd {
if ws == nil {
Expand All @@ -81,6 +122,11 @@ func (m *Model) createDiffTab(change *git.Change, mode git.DiffMode, ws *data.Wo
}
}

if idx, tab := m.findOpenDiffTab(ws, change.Path, mode); tab != nil {
logging.Info("Reusing diff tab: path=%s mode=%d workspace=%s", change.Path, mode, ws.Name)
return m.reuseDiffTab(ws, idx, tab, change, mode)
}

logging.Info("Creating diff tab: path=%s mode=%d workspace=%s", change.Path, mode, ws.Name)

tm := m.terminalMetrics()
Expand Down
Loading
Loading