diff --git a/internal/panels/gitinfo/gitinfo.go b/internal/panels/gitinfo/gitinfo.go index 60528e91..f16e9c78 100644 --- a/internal/panels/gitinfo/gitinfo.go +++ b/internal/panels/gitinfo/gitinfo.go @@ -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 @@ -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 } if directAction != "" { @@ -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 + } else { + p.pendingPath = "" + } return p, rightclick.FirstUseCmd(itemType) } // Already confirmed -- execute the configured action. @@ -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() 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 @@ -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 { @@ -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 } @@ -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) diff --git a/internal/panels/gitinfo/gitinfo_test.go b/internal/panels/gitinfo/gitinfo_test.go index c9c34210..a00da60f 100644 --- a/internal/panels/gitinfo/gitinfo_test.go +++ b/internal/panels/gitinfo/gitinfo_test.go @@ -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 // --------------------------------------------------------------------------- diff --git a/magefile.go b/magefile.go index 6db3e7d1..57dc0bef 100644 --- a/magefile.go +++ b/magefile.go @@ -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", @@ -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 } @@ -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)