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
11 changes: 10 additions & 1 deletion packages/treebeard/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -211,6 +211,9 @@ const mainviewRPC = BrowserView.defineRPC<TreebeardRPC>({
'launch:ghostty': ({ worktreePath }) => {
launchGhostty(worktreePath)
},
'launch:pippinShell': ({ worktreePath }) => {
launchPippinShell(worktreePath)
},
'launch:opencode': ({ worktreePath }) => {
launchOpencode(worktreePath)
},
Expand All @@ -237,6 +240,12 @@ const mainviewRPC = BrowserView.defineRPC<TreebeardRPC>({
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(),
Expand Down
25 changes: 24 additions & 1 deletion packages/treebeard/src/bun/services/launcher.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -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' })
)
})
})
25 changes: 25 additions & 0 deletions packages/treebeard/src/bun/services/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ end tell
Bun.spawn(['/usr/bin/osascript', '-e', script], { stdout: 'ignore', stderr: 'ignore' })
}

export async function launchPippinShell(worktreePath: string): Promise<void> {
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<void> {
// Use AppleScript to switch to an existing Ghostty tab for this worktree,
// or open a new tab running opencode if none exists.
Expand Down
32 changes: 26 additions & 6 deletions packages/treebeard/src/components/LaunchButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -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
}
})
}))
Expand All @@ -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(<LaunchButtons worktreePath={'/repo/worktrees/feat'} defaultIde="vscode" />)

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(<LaunchButtons worktreePath={'/repo/worktrees/feat'} defaultIde="vscode" />)

await waitFor(() => {
expect(screen.getAllByRole('button')).toHaveLength(2)
})
})

it('uses the configured IDE when set to intellij', () => {
Expand Down
37 changes: 36 additions & 1 deletion packages/treebeard/src/components/LaunchButtons.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -13,6 +13,30 @@ interface LaunchButtonsProps {

export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps) {
const ide = IDE_REGISTRY[defaultIde]
const [pippinPath, setPippinPath] = useState<string | null>(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 })
Expand All @@ -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 (
<Group gap={4}>
<Tooltip label={`Open in ${ide.label}`}>
Expand All @@ -34,6 +62,13 @@ export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps)
<IconGhost size={16} />
</ActionIcon>
</Tooltip>
{pippinPath && (
<Tooltip label="Open Pippin shell">
<ActionIcon variant="subtle" color="grape" size="sm" onClick={handlePippinShell}>
<IconPrison size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
)
}
8 changes: 8 additions & 0 deletions packages/treebeard/src/shared/rpc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -106,6 +110,10 @@ export type TreebeardRPC = {
params: Record<string, never>
response: string | null
}
'system:pippinPath': {
params: Record<string, never>
response: string | null
}
'dialog:openDirectory': {
params: Record<string, never>
response: string | null
Expand Down
Loading