From 3b5eaafd315479f9d82e97eae54c3956ef333aaf Mon Sep 17 00:00:00 2001 From: Martin Laporte Date: Wed, 8 Apr 2026 14:31:02 +0200 Subject: [PATCH] feat: add Pippin shell action button to worktree launch buttons --- packages/treebeard/src/bun/index.ts | 11 +++++- .../src/bun/services/launcher.test.ts | 25 ++++++++++++- .../treebeard/src/bun/services/launcher.ts | 25 +++++++++++++ .../src/components/LaunchButtons.test.tsx | 32 +++++++++++++--- .../src/components/LaunchButtons.tsx | 37 ++++++++++++++++++- packages/treebeard/src/shared/rpc-types.ts | 8 ++++ 6 files changed, 129 insertions(+), 9 deletions(-) diff --git a/packages/treebeard/src/bun/index.ts b/packages/treebeard/src/bun/index.ts index 38050d1..f3de9cf 100644 --- a/packages/treebeard/src/bun/index.ts +++ b/packages/treebeard/src/bun/index.ts @@ -18,7 +18,7 @@ import { } from './services/git' import { getPRForBranch } from './services/github' import { getJiraIssue, getMyJiraIssues } from './services/jira' -import { launchIde, launchGhostty, launchOpencode, launchURL } from './services/launcher' +import { launchGhostty, launchIde, launchOpencode, launchPippinShell, launchURL } from './services/launcher' import { getShellEnv } from './services/shell-env' import type { TreebeardRPC } from '../shared/rpc-types' import type { AppConfig, DependencyStatus } from '../shared/types' @@ -211,6 +211,9 @@ const mainviewRPC = BrowserView.defineRPC({ 'launch:ghostty': ({ worktreePath }) => { launchGhostty(worktreePath) }, + 'launch:pippinShell': ({ worktreePath }) => { + launchPippinShell(worktreePath) + }, 'launch:opencode': ({ worktreePath }) => { launchOpencode(worktreePath) }, @@ -237,6 +240,12 @@ const mainviewRPC = BrowserView.defineRPC({ const result = (await new Response(proc.stdout).text()).trim() return result || null }, + 'system:pippinPath': async () => { + const env = await getShellEnv() + const proc = Bun.spawn(['which', 'pippin'], { stdout: 'pipe', stderr: 'ignore', env }) + const result = (await new Response(proc.stdout).text()).trim() + return result || null + }, 'dialog:openDirectory': async () => { const paths = await Utils.openFileDialog({ startingFolder: os.homedir(), diff --git a/packages/treebeard/src/bun/services/launcher.test.ts b/packages/treebeard/src/bun/services/launcher.test.ts index b3717f9..f3e22eb 100644 --- a/packages/treebeard/src/bun/services/launcher.test.ts +++ b/packages/treebeard/src/bun/services/launcher.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { launchGhostty, launchIde } from './launcher' +import { launchGhostty, launchIde, launchPippinShell } from './launcher' import { setBunSpawnQueue } from '../../test/bun' vi.mock('./shell-env', () => ({ @@ -39,4 +39,27 @@ describe('launcher service', () => { expect.objectContaining({ stdout: 'ignore', stderr: 'ignore' }) ) }) + + it('launches a Ghostty tab running pippin shell in the worktree', async () => { + const spawn = setBunSpawnQueue([{ stdout: '/opt/homebrew/bin/pippin\n' }, { stdout: '' }]) + + await launchPippinShell('/repo/worktree') + + expect(spawn).toHaveBeenCalledWith( + ['which', 'pippin'], + expect.objectContaining({ stdout: 'pipe', stderr: 'ignore', env: { PATH: '/usr/bin' } }) + ) + expect(spawn).toHaveBeenCalledWith( + ['/usr/bin/osascript', '-e', expect.stringContaining('set command of cfg to "/opt/homebrew/bin/pippin shell"')], + expect.objectContaining({ stdout: 'ignore', stderr: 'ignore' }) + ) + expect(spawn).toHaveBeenCalledWith( + ['/usr/bin/osascript', '-e', expect.stringContaining('set environment variables of cfg to {"PATH=/usr/bin"}')], + expect.objectContaining({ stdout: 'ignore', stderr: 'ignore' }) + ) + expect(spawn).toHaveBeenCalledWith( + ['/usr/bin/osascript', '-e', expect.stringContaining('set initial working directory of cfg to "/repo/worktree"')], + expect.objectContaining({ stdout: 'ignore', stderr: 'ignore' }) + ) + }) }) diff --git a/packages/treebeard/src/bun/services/launcher.ts b/packages/treebeard/src/bun/services/launcher.ts index f6f63cd..2b27826 100644 --- a/packages/treebeard/src/bun/services/launcher.ts +++ b/packages/treebeard/src/bun/services/launcher.ts @@ -40,6 +40,31 @@ end tell Bun.spawn(['/usr/bin/osascript', '-e', script], { stdout: 'ignore', stderr: 'ignore' }) } +export async function launchPippinShell(worktreePath: string): Promise { + const env = await getShellEnv() + const whichProc = Bun.spawn(['which', 'pippin'], { stdout: 'pipe', stderr: 'ignore', env }) + const pippinPath = (await new Response(whichProc.stdout).text()).trim() + if (!pippinPath) return + const shellPath = env.PATH || process.env.PATH || '' + + const script = ` +tell application "Ghostty" + set cfg to new surface configuration + set initial working directory of cfg to "${worktreePath}" + set command of cfg to "${pippinPath} shell" + set environment variables of cfg to {"PATH=${shellPath}"} + if (count of windows) is 0 then + new window with configuration cfg + return + end if + tell window 1 + new tab with configuration cfg + end tell +end tell +` + Bun.spawn(['/usr/bin/osascript', '-e', script], { stdout: 'ignore', stderr: 'ignore' }) +} + export async function launchOpencode(worktreePath: string): Promise { // Use AppleScript to switch to an existing Ghostty tab for this worktree, // or open a new tab running opencode if none exists. diff --git a/packages/treebeard/src/components/LaunchButtons.test.tsx b/packages/treebeard/src/components/LaunchButtons.test.tsx index 6300944..5d260a8 100644 --- a/packages/treebeard/src/components/LaunchButtons.test.tsx +++ b/packages/treebeard/src/components/LaunchButtons.test.tsx @@ -1,18 +1,20 @@ -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { LaunchButtons } from './LaunchButtons' import { renderWithMantine } from '../test/render' const launchIdeRequest = vi.fn() const launchGhosttyRequest = vi.fn() -const opencodePathRequest = vi.fn() +const launchPippinShellRequest = vi.fn() +const systemPippinPathRequest = vi.fn() vi.mock('../rpc', () => ({ rpc: () => ({ request: { 'launch:ide': launchIdeRequest, 'launch:ghostty': launchGhosttyRequest, - 'system:opencodePath': opencodePathRequest + 'launch:pippinShell': launchPippinShellRequest, + 'system:pippinPath': systemPippinPathRequest } }) })) @@ -21,21 +23,39 @@ describe('LaunchButtons', () => { beforeEach(() => { launchIdeRequest.mockReset() launchGhosttyRequest.mockReset() - opencodePathRequest.mockReset() + launchPippinShellRequest.mockReset() + systemPippinPathRequest.mockReset() launchIdeRequest.mockResolvedValue(undefined) launchGhosttyRequest.mockResolvedValue(undefined) - opencodePathRequest.mockResolvedValue(null) + launchPippinShellRequest.mockResolvedValue(undefined) + systemPippinPathRequest.mockResolvedValue('/opt/homebrew/bin/pippin') }) - it('launches configured IDE and Ghostty for the selected worktree', () => { + it('launches configured IDE, Ghostty, and Pippin shell for the selected worktree', async () => { renderWithMantine() + await waitFor(() => { + expect(screen.getAllByRole('button')).toHaveLength(3) + }) + const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]) fireEvent.click(buttons[1]) + fireEvent.click(buttons[2]) expect(launchIdeRequest).toHaveBeenCalledWith({ ideId: 'vscode', worktreePath: '/repo/worktrees/feat' }) expect(launchGhosttyRequest).toHaveBeenCalledWith({ worktreePath: '/repo/worktrees/feat' }) + expect(launchPippinShellRequest).toHaveBeenCalledWith({ worktreePath: '/repo/worktrees/feat' }) + }) + + it('hides the Pippin shell button when pippin is unavailable', async () => { + systemPippinPathRequest.mockResolvedValue(null) + + renderWithMantine() + + await waitFor(() => { + expect(screen.getAllByRole('button')).toHaveLength(2) + }) }) it('uses the configured IDE when set to intellij', () => { diff --git a/packages/treebeard/src/components/LaunchButtons.tsx b/packages/treebeard/src/components/LaunchButtons.tsx index 8ed9950..d5c91be 100644 --- a/packages/treebeard/src/components/LaunchButtons.tsx +++ b/packages/treebeard/src/components/LaunchButtons.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { ActionIcon, Group, Tooltip } from '@mantine/core' -import { IconGhost } from '@tabler/icons-react' +import { IconGhost, IconPrison } from '@tabler/icons-react' import { IdeIcon } from './IdeIcon' import { IDE_REGISTRY } from '../shared/ide-registry' import { rpc } from '../rpc' @@ -13,6 +13,30 @@ interface LaunchButtonsProps { export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps) { const ide = IDE_REGISTRY[defaultIde] + const [pippinPath, setPippinPath] = useState(null) + + useEffect(() => { + let cancelled = false + + const loadPippinPath = async () => { + try { + const result = await rpc().request['system:pippinPath']({}) + if (!cancelled) { + setPippinPath(result) + } + } catch { + if (!cancelled) { + setPippinPath(null) + } + } + } + + void loadPippinPath() + + return () => { + cancelled = true + } + }, []) const handleIde = async () => { await rpc().request['launch:ide']({ ideId: defaultIde, worktreePath }) @@ -22,6 +46,10 @@ export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps) await rpc().request['launch:ghostty']({ worktreePath }) } + const handlePippinShell = async () => { + await rpc().request['launch:pippinShell']({ worktreePath }) + } + return ( @@ -34,6 +62,13 @@ export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps) + {pippinPath && ( + + + + + + )} ) } diff --git a/packages/treebeard/src/shared/rpc-types.ts b/packages/treebeard/src/shared/rpc-types.ts index 5a5a3d5..f86955d 100644 --- a/packages/treebeard/src/shared/rpc-types.ts +++ b/packages/treebeard/src/shared/rpc-types.ts @@ -90,6 +90,10 @@ export type TreebeardRPC = { params: { worktreePath: string } response: void } + 'launch:pippinShell': { + params: { worktreePath: string } + response: void + } 'launch:opencode': { params: { worktreePath: string } response: void @@ -106,6 +110,10 @@ export type TreebeardRPC = { params: Record response: string | null } + 'system:pippinPath': { + params: Record + response: string | null + } 'dialog:openDirectory': { params: Record response: string | null