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
47 changes: 39 additions & 8 deletions internal/panels/gitinfo/gitinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,7 @@ type Panel struct {
iconMode string // "nerd" or "ascii"
repoRoot string
pendingName string // name for pending operation
pendingPath string // path captured at double-click time (survives async modal delay)
ghOwner string
ghRepo string
ghUser string // authenticated user login
Expand Down Expand Up @@ -1539,6 +1540,11 @@ func (p *Panel) handleMouseRightClick(msg panels.PanelMouseRightClickMsg) (panel
cmd, directAction := rightclick.Cmd(p.actionsCfg, itemType, label)
if cmd != nil {
p.pending = opRightClickPick
if item.kind == kindWorktree {
p.pendingPath = item.worktree.Path
} else {
p.pendingPath = ""
}
return p, cmd
Comment thread
jongio marked this conversation as resolved.
}
if directAction != "" {
Expand Down Expand Up @@ -1884,6 +1890,13 @@ func (p *Panel) doAction() (panels.Panel, tea.Cmd) {
if !p.actionsCfg.IsConfirmed(string(itemType)) {
p.pending = opFirstUseConfirm
p.pendingName = string(itemType)
// Capture path at double-click time so it survives cursor resets
// that may happen during the async modal delay (e.g. data reload).
if item.kind == kindWorktree {
p.pendingPath = item.worktree.Path
Comment thread
jongio marked this conversation as resolved.
} else {
p.pendingPath = ""
}
return p, rightclick.FirstUseCmd(itemType)
}
// Already confirmed -- execute the configured action.
Expand Down Expand Up @@ -2037,19 +2050,25 @@ func (p *Panel) executeRightClickAction(action actions.ActionID) (panels.Panel,
return p.copyAndToast(item.branch.Name)
}
case kindWorktree:
wtPath := p.pendingPath
if wtPath == "" {
wtPath = item.worktree.Path
}
switch action { //nolint:exhaustive // only relevant cases handled
case actions.ActionChangeDirectory:
// pendingPath is consumed inside requestWorktreeSwitch
return p.requestWorktreeSwitch()
Comment thread
jongio marked this conversation as resolved.
case actions.ActionOpenTerminal:
path := item.worktree.Path
p.pendingPath = ""
return p, func() tea.Msg {
if err := panels.OpenInTerminal(path); err != nil {
if err := panels.OpenInTerminal(wtPath); err != nil {
return notify.ShowToastMsg{Message: "Terminal error: " + err.Error(), Level: notify.Error}
}
return notify.ShowToastMsg{Message: "Opened terminal at " + path, Level: notify.Success}
return notify.ShowToastMsg{Message: "Opened terminal at " + wtPath, Level: notify.Success}
}
case actions.ActionCopyPath:
return p.copyAndToast(item.worktree.Path)
p.pendingPath = ""
return p.copyAndToast(wtPath)
}
case kindRemote:
switch action { //nolint:exhaustive // only relevant cases handled
Expand Down Expand Up @@ -2303,11 +2322,19 @@ func (p *Panel) doReflogCheckout() (panels.Panel, tea.Cmd) {
}

func (p *Panel) requestWorktreeSwitch() (panels.Panel, tea.Cmd) {
wt := p.selectedWorktree()
if wt == nil {
return p, nil
// Use pendingPath captured at double-click time if available (survives
// cursor resets from async data reloads during modal display).
var path string
if p.pendingPath != "" {
path = p.pendingPath
p.pendingPath = ""
} else {
wt := p.selectedWorktree()
if wt == nil {
return p, nil
}
path = wt.Path
}
path := wt.Path
if p.cfg.WorktreeOpenMode == "new_terminal" {
return p, func() tea.Msg {
if err := panels.OpenInTerminal(path); err != nil {
Expand Down Expand Up @@ -2509,8 +2536,10 @@ func (p *Panel) doFetch() (panels.Panel, tea.Cmd) {
func (p *Panel) handleModalResult(msg notify.ModalResultMsg) (panels.Panel, tea.Cmd) {
op := p.pending
name := p.pendingName
pendingPath := p.pendingPath
p.pending = opNone
p.pendingName = ""
p.pendingPath = ""
if !msg.Accept {
return p, nil
}
Expand Down Expand Up @@ -2643,8 +2672,10 @@ func (p *Panel) handleModalResult(msg notify.ModalResultMsg) (panels.Panel, tea.
if msg.Remember {
config.SaveDoubleClickChoice(&p.actionsCfg, name, msg.Value)
}
p.pendingPath = pendingPath // restore for executeRightClickAction
return p.executeRightClickAction(actions.ActionID(msg.Value))
case opRightClickPick:
p.pendingPath = pendingPath // restore for executeRightClickAction
return p.executeRightClickAction(actions.ActionID(msg.Value))
case opTagCreate:
tagName := strings.TrimSpace(msg.Value)
Expand Down
42 changes: 42 additions & 0 deletions internal/panels/gitinfo/gitinfo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4062,6 +4062,48 @@ func TestDoAction_BlockedWhilePending(t *testing.T) {
assert.Equal(t, "123:ci", p.pendingName)
}

func TestDoAction_FirstUseWorktreeCursorReset(t *testing.T) {
// Regression test: when a user double-clicks a worktree (first use),
// the worktree path must be captured at double-click time. If a data
// reload resets the cursor to 0 before the modal result arrives, the
// panel should still use the originally-clicked worktree path.
p := newTestPanel(defaultMock())
p.actionsCfg = config.ActionsConfig{} // unconfirmed
p.activeTab = tabWorktrees

// Ensure there are multiple worktrees and cursor is on the second one.
p.tabItems[tabWorktrees] = []listItem{
{kind: kindWorktree, worktree: git.Worktree{Path: "/repo", Branch: "main"}},
{kind: kindWorktree, worktree: git.Worktree{Path: "/worktrees/feature", Branch: "feature"}},
}
p.tabCursor[tabWorktrees] = 1 // user clicked "feature" worktree

// Step 1: doAction shows the first-use modal.
_, cmd := p.doAction()
require.NotNil(t, cmd)
assert.Equal(t, opFirstUseConfirm, p.pending)
assert.Equal(t, "/worktrees/feature", p.pendingPath)

// Simulate a data reload that resets cursor to 0 (e.g. from fsnotify).
p.tabCursor[tabWorktrees] = 0

// Step 2: User selects "change_directory" from the modal.
_, cmd = p.handleModalResult(notify.ModalResultMsg{
Accept: true,
Value: string(actions.ActionChangeDirectory),
})
require.NotNil(t, cmd, "accepting worktree change_directory should produce a command")

// The command should produce an opResultMsg with the ORIGINAL path,
// not the path at cursor 0.
msg := cmd()
result, ok := msg.(opResultMsg)
require.True(t, ok, "expected opResultMsg, got %T", msg)
assert.Equal(t, "worktree_switch", result.op)
assert.Equal(t, "/worktrees/feature", result.name,
"should use path captured at double-click time, not stale cursor")
}

// ---------------------------------------------------------------------------
// CI watch animation tests
// ---------------------------------------------------------------------------
Expand Down
48 changes: 47 additions & 1 deletion magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,15 @@ func ensurePath() error {
return nil
}

// Also check User PATH before trying to modify anything.
userPath, _ := cmdOutput("powershell", "-NoProfile", "-Command",
`[Environment]::GetEnvironmentVariable('Path','User')`)
userPath = strings.TrimSpace(userPath)
if containsPath(userPath, binDir) {
ensureSessionPath(binDir)
return nil
}

fmt.Printf("\n=== Adding %s to system PATH ===\n", binDir)
newPath := binDir + ";" + machinePath
err := exec.Command("powershell", "-NoProfile", "-Command",
Expand All @@ -1085,12 +1094,25 @@ func ensurePath() error {
`[Environment]::GetEnvironmentVariable('Path','User')`)
userPath = strings.TrimSpace(userPath)
if !containsPath(userPath, binDir) {
exec.Command("powershell", "-NoProfile", "-Command",
err = exec.Command("powershell", "-NoProfile", "-Command",
fmt.Sprintf(`[Environment]::SetEnvironmentVariable('Path','%s','User')`,
psSingleQuoteEscape(binDir+";"+userPath))).Run()
} else {
err = nil // already present in User PATH
}
}
if err == nil {
broadcastPathChange()
}
ensureSessionPath(binDir)
if err != nil {
fmt.Println(" ⚠ Could not update persistent PATH (tried Machine and User).")
fmt.Println(" To use in this terminal only, run:")
fmt.Printf(" $env:Path = \"%s;\" + $env:Path\n", binDir)
} else {
fmt.Println(" To use in this terminal, run:")
fmt.Printf(" $env:Path = \"%s;\" + $env:Path\n", binDir)
}
return nil
}

Expand Down Expand Up @@ -1171,6 +1193,30 @@ func ensureSessionPath(binDir string) {
os.Setenv("Path", current)
}

// broadcastPathChange sends WM_SETTINGCHANGE so new Explorer/shell windows
// pick up the modified persistent PATH immediately without requiring a reboot.
func broadcastPathChange() {
if runtime.GOOS != "windows" {
return
}
// SendMessageTimeout with HWND_BROADCAST notifies all top-level windows
// that the environment has changed. New terminals will read the updated
// persistent PATH; existing terminals remain unaffected (OS limitation).
script := `Add-Type -Namespace Win32 -Name NativeMethods -MemberDefinition @"
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern IntPtr SendMessageTimeout(
IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam,
uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);
"@
$HWND_BROADCAST = [IntPtr]0xFFFF
$WM_SETTINGCHANGE = 0x001A
$result = [UIntPtr]::Zero
[Win32.NativeMethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, "Environment", 0x0002, 5000, [ref]$result) | Out-Null`
if err := exec.Command("powershell", "-NoProfile", "-Command", script).Run(); err != nil {
fmt.Printf(" Warning: PATH broadcast failed: %v\n", err)
}
}

func verify() error {
outPath := filepath.Join(projectDir(), "bin", binaryName())
info, err := os.Stat(outPath)
Expand Down
Loading