Major features

This commit is contained in:
Neil 2026-03-17 01:40:24 -07:00
parent 06eac79279
commit 7f29249d48
66 changed files with 5230 additions and 628 deletions

View file

@ -9,7 +9,8 @@ export default defineConfig({
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
'@renderer': resolve('src/renderer/src'),
'@': resolve('src/renderer/src')
}
},
plugins: [react(), tailwindcss()]

View file

@ -22,6 +22,9 @@
"build:linux": "electron-vite build && electron-builder --linux"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"class-variance-authority": "^0.7.1",
@ -32,6 +35,7 @@
"radix-ui": "^1.4.3",
"restty": "^0.1.34",
"shadcn": "^4.0.8",
"simple-git": "^3.33.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.12"

View file

@ -8,6 +8,15 @@ importers:
.:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.4)
'@electron-toolkit/preload':
specifier: ^3.0.2
version: 3.0.2(electron@41.0.2)
@ -38,6 +47,9 @@ importers:
shadcn:
specifier: ^4.0.8
version: 4.0.8(@types/node@25.5.0)(typescript@5.9.3)
simple-git:
specifier: ^3.33.0
version: 3.33.0
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@ -259,6 +271,28 @@ packages:
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
engines: {node: '>= 8.9.0'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@dotenvx/dotenvx@1.55.1':
resolution: {integrity: sha512-WEuKyoe9CA7dfcFBnNbL0ndbCNcptaEYBygfFo9X1qEG+HD7xku4CYIplw6sbAHJavesZWbVBHeRSpvri0eKqw==}
hasBin: true
@ -716,6 +750,12 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kwsites/file-exists@1.1.1':
resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==}
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@malept/cross-spawn-promise@2.0.0':
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
engines: {node: '>= 12.13.0'}
@ -3871,6 +3911,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-git@3.33.0:
resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==}
simple-update-notifier@2.0.0:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'}
@ -4552,6 +4595,31 @@ snapshots:
ajv: 6.14.0
ajv-keywords: 3.5.2(ajv@6.14.0)
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@dotenvx/dotenvx@1.55.1':
dependencies:
commander: 11.1.0
@ -4917,6 +4985,14 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@kwsites/file-exists@1.1.1':
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
'@kwsites/promise-deferred@1.1.1': {}
'@malept/cross-spawn-promise@2.0.0':
dependencies:
cross-spawn: 7.0.6
@ -8259,6 +8335,14 @@ snapshots:
signal-exit@4.1.0: {}
simple-git@3.33.0:
dependencies:
'@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1
debug: 4.4.3
transitivePeerDependencies:
- supports-color
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.4

98
src/main/git/repo.ts Normal file
View file

@ -0,0 +1,98 @@
import { execSync } from 'child_process'
import { existsSync, statSync } from 'fs'
import { join, basename } from 'path'
/**
* Check if a path is a valid git repository (regular or bare).
*/
export function isGitRepo(path: string): boolean {
try {
if (!existsSync(path) || !statSync(path).isDirectory()) return false
// .git dir or file (for worktrees) or bare repo
if (existsSync(join(path, '.git'))) return true
// Might be a bare repo — ask git
const result = execSync('git rev-parse --is-inside-work-tree', {
cwd: path,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim()
return result === 'true'
} catch {
// Also check if it's a bare repo
try {
const result = execSync('git rev-parse --is-bare-repository', {
cwd: path,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim()
return result === 'true'
} catch {
return false
}
}
}
/**
* Get a human-readable name for the repo from its path.
*/
export function getRepoName(path: string): string {
const name = basename(path)
// Strip .git suffix from bare repos
return name.endsWith('.git') ? name.slice(0, -4) : name
}
/**
* Get the remote origin URL, or null if not set.
*/
export function getRemoteUrl(path: string): string | null {
try {
return execSync('git remote get-url origin', {
cwd: path,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim()
} catch {
return null
}
}
/**
* Get git config user.name for the repo.
*/
export function getGitUsername(path: string): string {
try {
return execSync('git config user.name', {
cwd: path,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim()
} catch {
return ''
}
}
/**
* Detect the default branch (main or master).
*/
export function getDefaultBranch(path: string): string {
try {
// Check if 'main' branch exists
execSync('git rev-parse --verify main', {
cwd: path,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
})
return 'main'
} catch {
try {
execSync('git rev-parse --verify master', {
cwd: path,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
})
return 'master'
} catch {
return 'main'
}
}
}

87
src/main/git/worktree.ts Normal file
View file

@ -0,0 +1,87 @@
import { execSync } from 'child_process'
import type { GitWorktreeInfo } from '../../shared/types'
/**
* Parse the porcelain output of `git worktree list --porcelain`.
*/
export function parseWorktreeList(output: string): GitWorktreeInfo[] {
const worktrees: GitWorktreeInfo[] = []
const blocks = output.trim().split('\n\n')
for (const block of blocks) {
if (!block.trim()) continue
const lines = block.trim().split('\n')
let path = ''
let head = ''
let branch = ''
let isBare = false
for (const line of lines) {
if (line.startsWith('worktree ')) {
path = line.slice('worktree '.length)
} else if (line.startsWith('HEAD ')) {
head = line.slice('HEAD '.length)
} else if (line.startsWith('branch ')) {
branch = line.slice('branch '.length)
} else if (line === 'bare') {
isBare = true
}
}
if (path) {
worktrees.push({ path, head, branch, isBare })
}
}
return worktrees
}
/**
* List all worktrees for a git repo at the given path.
*/
export function listWorktrees(repoPath: string): GitWorktreeInfo[] {
try {
const output = execSync('git worktree list --porcelain', {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
})
return parseWorktreeList(output)
} catch {
return []
}
}
/**
* Create a new worktree.
* @param repoPath - Path to the main repo (or bare repo)
* @param worktreePath - Absolute path where the worktree will be created
* @param branch - Branch name for the new worktree
* @param baseBranch - Optional base branch to create from (defaults to HEAD)
*/
export function addWorktree(
repoPath: string,
worktreePath: string,
branch: string,
baseBranch?: string
): void {
const base = baseBranch ? ` ${baseBranch}` : ''
execSync(`git worktree add -b "${branch}" "${worktreePath}"${base}`, {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
})
}
/**
* Remove a worktree.
*/
export function removeWorktree(repoPath: string, worktreePath: string, force = false): void {
const forceFlag = force ? ' --force' : ''
execSync(`git worktree remove "${worktreePath}"${forceFlag}`, {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
})
}

126
src/main/github/client.ts Normal file
View file

@ -0,0 +1,126 @@
import { execSync } from 'child_process'
import type { PRInfo, IssueInfo, CheckStatus } from '../../shared/types'
/**
* Get PR info for a given branch using gh CLI.
* Returns null if gh is not installed, or no PR exists for the branch.
*/
export function getPRForBranch(repoPath: string, branch: string): PRInfo | null {
try {
// Strip refs/heads/ prefix if present
const branchName = branch.replace(/^refs\/heads\//, '')
const raw = execSync(
`gh pr view "${branchName}" --json number,title,state,url,statusCheckRollup,updatedAt`,
{
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
}
)
const data = JSON.parse(raw)
return {
number: data.number,
title: data.title,
state: mapPRState(data.state),
url: data.url,
checksStatus: deriveCheckStatus(data.statusCheckRollup),
updatedAt: data.updatedAt
}
} catch {
return null
}
}
/**
* Get a single issue by number.
*/
export function getIssue(repoPath: string, issueNumber: number): IssueInfo | null {
try {
const raw = execSync(`gh issue view ${issueNumber} --json number,title,state,url,labels`, {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
})
const data = JSON.parse(raw)
return {
number: data.number,
title: data.title,
state: data.state?.toLowerCase() === 'open' ? 'open' : 'closed',
url: data.url,
labels: (data.labels || []).map((l: { name: string }) => l.name)
}
} catch {
return null
}
}
/**
* List issues for a repo.
*/
export function listIssues(repoPath: string, limit = 20): IssueInfo[] {
try {
const raw = execSync(`gh issue list --json number,title,state,url,labels --limit ${limit}`, {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
})
const data = JSON.parse(raw) as Array<{
number: number
title: string
state: string
url: string
labels: Array<{ name: string }>
}>
return data.map((d) => ({
number: d.number,
title: d.title,
state: d.state?.toLowerCase() === 'open' ? ('open' as const) : ('closed' as const),
url: d.url,
labels: (d.labels || []).map((l) => l.name)
}))
} catch {
return []
}
}
function mapPRState(state: string): PRInfo['state'] {
const s = state?.toUpperCase()
if (s === 'MERGED') return 'merged'
if (s === 'CLOSED') return 'closed'
// gh CLI returns isDraft separately, but state field is OPEN for drafts too
return 'open'
}
function deriveCheckStatus(rollup: unknown[] | null | undefined): CheckStatus {
if (!rollup || !Array.isArray(rollup) || rollup.length === 0) return 'pending'
let hasFailure = false
let hasPending = false
for (const check of rollup as Array<{ status?: string; conclusion?: string; state?: string }>) {
const conclusion = check.conclusion?.toUpperCase()
const status = check.status?.toUpperCase()
const state = check.state?.toUpperCase()
if (
conclusion === 'FAILURE' ||
conclusion === 'TIMED_OUT' ||
conclusion === 'CANCELLED' ||
state === 'FAILURE' ||
state === 'ERROR'
) {
hasFailure = true
} else if (
status === 'IN_PROGRESS' ||
status === 'QUEUED' ||
status === 'PENDING' ||
state === 'PENDING'
) {
hasPending = true
}
}
if (hasFailure) return 'failure'
if (hasPending) return 'pending'
return 'success'
}

126
src/main/hooks.ts Normal file
View file

@ -0,0 +1,126 @@
import { readFileSync, existsSync } from 'fs'
import { join } from 'path'
import { exec } from 'child_process'
import type { OrcaHooks } from '../shared/types'
const HOOK_TIMEOUT = 120_000 // 2 minutes
/**
* Parse a simple orca.yaml file. Handles only the `scripts:` block with
* multiline string values (YAML block scalar `|`).
*/
function parseOrcaYaml(content: string): OrcaHooks | null {
const hooks: OrcaHooks = { scripts: {} }
// Match top-level "scripts:" block
const scriptsMatch = content.match(/^scripts:\s*$/m)
if (!scriptsMatch) return null
const afterScripts = content.slice(scriptsMatch.index! + scriptsMatch[0].length)
const lines = afterScripts.split('\n')
let currentKey: 'setup' | 'archive' | null = null
let currentValue = ''
for (const line of lines) {
// Another top-level key (not indented) — stop parsing scripts block
if (/^\S/.test(line) && line.trim().length > 0) break
// Indented key like " setup: |" or " archive: |"
const keyMatch = line.match(/^ (setup|archive):\s*\|?\s*$/)
if (keyMatch) {
// Save previous key
if (currentKey) {
hooks.scripts[currentKey] = currentValue.trimEnd()
}
currentKey = keyMatch[1] as 'setup' | 'archive'
currentValue = ''
continue
}
// Content line (indented by 4+ spaces under a key)
if (currentKey && /^ /.test(line)) {
currentValue += line.slice(4) + '\n'
}
}
// Save last key
if (currentKey) {
hooks.scripts[currentKey] = currentValue.trimEnd()
}
if (!hooks.scripts.setup && !hooks.scripts.archive) return null
return hooks
}
/**
* Load hooks from orca.yaml in the given repo root.
*/
export function loadHooks(repoPath: string): OrcaHooks | null {
const yamlPath = join(repoPath, 'orca.yaml')
if (!existsSync(yamlPath)) return null
try {
const content = readFileSync(yamlPath, 'utf-8')
return parseOrcaYaml(content)
} catch {
return null
}
}
/**
* Check whether an orca.yaml exists for a repo.
*/
export function hasHooksFile(repoPath: string): boolean {
return existsSync(join(repoPath, 'orca.yaml'))
}
/**
* Run a named hook script in the given working directory.
*/
export function runHook(
hookName: 'setup' | 'archive',
cwd: string,
repoPath: string
): Promise<{ success: boolean; output: string }> {
const hooks = loadHooks(repoPath)
const script = hooks?.scripts[hookName]
if (!script) {
return Promise.resolve({ success: true, output: '' })
}
return new Promise((resolve) => {
exec(
script,
{
cwd,
timeout: HOOK_TIMEOUT,
shell: '/bin/bash',
env: {
...process.env,
ORCA_ROOT_PATH: repoPath,
ORCA_WORKTREE_PATH: cwd,
// Compat with conductor.json users
CONDUCTOR_ROOT_PATH: repoPath,
GHOSTX_ROOT_PATH: repoPath
}
},
(error, stdout, stderr) => {
if (error) {
console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, error.message)
resolve({
success: false,
output: `${stdout}\n${stderr}\n${error.message}`.trim()
})
} else {
console.log(`[hooks] ${hookName} hook completed in ${cwd}`)
resolve({
success: true,
output: `${stdout}\n${stderr}`.trim()
})
}
}
)
})
}

View file

@ -1,21 +1,21 @@
import { app, shell, BrowserWindow, ipcMain, Menu, nativeImage } from 'electron'
import { app, shell, BrowserWindow, Menu, nativeImage } from 'electron'
import { join } from 'path'
import { execSync } from 'child_process'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import devIcon from '../../resources/icon-dev.png?asset'
import * as pty from 'node-pty'
import { Store } from './persistence'
import { registerRepoHandlers } from './ipc/repos'
import { registerWorktreeHandlers } from './ipc/worktrees'
import { registerPtyHandlers, killAllPty } from './ipc/pty'
import { registerGitHubHandlers } from './ipc/github'
import { registerSettingsHandlers } from './ipc/settings'
import { registerShellHandlers } from './ipc/shell'
// Enable WebGPU in Electron
app.commandLine.appendSwitch('enable-features', 'Vulkan,UseSkiaGraphite')
app.commandLine.appendSwitch('enable-unsafe-webgpu')
// ---------------------------------------------------------------------------
// PTY instance tracking
// ---------------------------------------------------------------------------
let ptyCounter = 0
const ptyProcesses = new Map<string, pty.IPty>()
// ---------------------------------------------------------------------------
// Window creation
// ---------------------------------------------------------------------------
@ -56,49 +56,6 @@ function createWindow(): BrowserWindow {
return mainWindow
}
// ---------------------------------------------------------------------------
// Worktree helpers
// ---------------------------------------------------------------------------
interface WorktreeInfo {
path: string
head: string
branch: string
isBare: boolean
}
function parseWorktreeList(output: string): WorktreeInfo[] {
const worktrees: WorktreeInfo[] = []
const blocks = output.trim().split('\n\n')
for (const block of blocks) {
if (!block.trim()) continue
const lines = block.trim().split('\n')
let path = ''
let head = ''
let branch = ''
let isBare = false
for (const line of lines) {
if (line.startsWith('worktree ')) {
path = line.slice('worktree '.length)
} else if (line.startsWith('HEAD ')) {
head = line.slice('HEAD '.length)
} else if (line.startsWith('branch ')) {
branch = line.slice('branch '.length)
} else if (line === 'bare') {
isBare = true
}
}
if (path) {
worktrees.push({ path, head, branch, isBare })
}
}
return worktrees
}
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
@ -108,7 +65,7 @@ app.whenReady().then(() => {
if (process.platform === 'darwin') {
const dockIcon = nativeImage.createFromPath(is.dev ? devIcon : icon)
app.dock.setIcon(dockIcon)
app.dock?.setIcon(dockIcon)
}
app.on('browser-window-created', (_, window) => {
@ -165,101 +122,21 @@ app.whenReady().then(() => {
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
// Initialize persistence
const store = new Store()
// Create window
const mainWindow = createWindow()
// -------------------------------------------------------------------------
// PTY IPC handlers
// -------------------------------------------------------------------------
ipcMain.handle('pty:spawn', (_event, args: { cols: number; rows: number; cwd?: string }) => {
const id = String(++ptyCounter)
const shell = process.env.SHELL || '/bin/zsh'
// Register all IPC handlers
registerRepoHandlers(mainWindow, store)
registerWorktreeHandlers(mainWindow, store)
registerPtyHandlers(mainWindow)
registerGitHubHandlers()
registerSettingsHandlers(store)
registerShellHandlers()
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: args.cols,
rows: args.rows,
cwd: args.cwd || process.env.HOME || '/',
env: {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor'
} as Record<string, string>
})
ptyProcesses.set(id, ptyProcess)
ptyProcess.onData((data) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:data', { id, data })
}
})
ptyProcess.onExit(({ exitCode }) => {
ptyProcesses.delete(id)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:exit', { id, code: exitCode })
}
})
return { id }
})
ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => {
const proc = ptyProcesses.get(args.id)
if (proc) {
proc.write(args.data)
}
})
ipcMain.handle('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
const proc = ptyProcesses.get(args.id)
if (proc) {
proc.resize(args.cols, args.rows)
}
})
ipcMain.handle('pty:kill', (_event, args: { id: string }) => {
const proc = ptyProcesses.get(args.id)
if (proc) {
proc.kill()
ptyProcesses.delete(args.id)
}
})
// -------------------------------------------------------------------------
// Worktree IPC handlers
// -------------------------------------------------------------------------
ipcMain.handle('worktrees:list', (_event, args: { cwd: string }): WorktreeInfo[] => {
try {
const output = execSync('git worktree list --porcelain', {
cwd: args.cwd,
encoding: 'utf-8'
})
return parseWorktreeList(output)
} catch {
return []
}
})
ipcMain.handle('worktrees:get-current', (_event): WorktreeInfo[] => {
try {
const repoRoot = execSync('git rev-parse --show-toplevel', {
encoding: 'utf-8'
}).trim()
const output = execSync('git worktree list --porcelain', {
cwd: repoRoot,
encoding: 'utf-8'
})
return parseWorktreeList(output)
} catch {
return []
}
})
// -------------------------------------------------------------------------
// macOS re-activate
// -------------------------------------------------------------------------
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
@ -269,10 +146,7 @@ app.whenReady().then(() => {
// Cleanup
// ---------------------------------------------------------------------------
app.on('before-quit', () => {
for (const [id, proc] of ptyProcesses) {
proc.kill()
ptyProcesses.delete(id)
}
killAllPty()
})
app.on('window-all-closed', () => {

16
src/main/ipc/github.ts Normal file
View file

@ -0,0 +1,16 @@
import { ipcMain } from 'electron'
import { getPRForBranch, getIssue, listIssues } from '../github/client'
export function registerGitHubHandlers(): void {
ipcMain.handle('gh:prForBranch', (_event, args: { repoPath: string; branch: string }) => {
return getPRForBranch(args.repoPath, args.branch)
})
ipcMain.handle('gh:issue', (_event, args: { repoPath: string; number: number }) => {
return getIssue(args.repoPath, args.number)
})
ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => {
return listIssues(args.repoPath, args.limit)
})
}

73
src/main/ipc/pty.ts Normal file
View file

@ -0,0 +1,73 @@
import { BrowserWindow, ipcMain } from 'electron'
import * as pty from 'node-pty'
let ptyCounter = 0
const ptyProcesses = new Map<string, pty.IPty>()
export function registerPtyHandlers(mainWindow: BrowserWindow): void {
ipcMain.handle('pty:spawn', (_event, args: { cols: number; rows: number; cwd?: string }) => {
const id = String(++ptyCounter)
const shellPath = process.env.SHELL || '/bin/zsh'
const ptyProcess = pty.spawn(shellPath, [], {
name: 'xterm-256color',
cols: args.cols,
rows: args.rows,
cwd: args.cwd || process.env.HOME || '/',
env: {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor'
} as Record<string, string>
})
ptyProcesses.set(id, ptyProcess)
ptyProcess.onData((data) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:data', { id, data })
}
})
ptyProcess.onExit(({ exitCode }) => {
ptyProcesses.delete(id)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:exit', { id, code: exitCode })
}
})
return { id }
})
ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => {
const proc = ptyProcesses.get(args.id)
if (proc) {
proc.write(args.data)
}
})
ipcMain.handle('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
const proc = ptyProcesses.get(args.id)
if (proc) {
proc.resize(args.cols, args.rows)
}
})
ipcMain.handle('pty:kill', (_event, args: { id: string }) => {
const proc = ptyProcesses.get(args.id)
if (proc) {
proc.kill()
ptyProcesses.delete(args.id)
}
})
}
/**
* Kill all PTY processes. Call on app quit.
*/
export function killAllPty(): void {
for (const [id, proc] of ptyProcesses) {
proc.kill()
ptyProcesses.delete(id)
}
}

65
src/main/ipc/repos.ts Normal file
View file

@ -0,0 +1,65 @@
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { randomUUID } from 'crypto'
import type { Store } from '../persistence'
import type { Repo } from '../../shared/types'
import { REPO_COLORS } from '../../shared/constants'
import { isGitRepo, getRepoName } from '../git/repo'
export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void {
ipcMain.handle('repos:list', () => {
return store.getRepos()
})
ipcMain.handle('repos:add', async (_event, args: { path: string }) => {
if (!isGitRepo(args.path)) {
throw new Error(`Not a valid git repository: ${args.path}`)
}
// Check if already added
const existing = store.getRepos().find((r) => r.path === args.path)
if (existing) return existing
const repo: Repo = {
id: randomUUID(),
path: args.path,
displayName: getRepoName(args.path),
badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length],
addedAt: Date.now()
}
store.addRepo(repo)
notifyReposChanged(mainWindow)
return repo
})
ipcMain.handle('repos:remove', (_event, args: { repoId: string }) => {
store.removeRepo(args.repoId)
notifyReposChanged(mainWindow)
})
ipcMain.handle(
'repos:update',
(
_event,
args: { repoId: string; updates: Partial<Pick<Repo, 'displayName' | 'badgeColor'>> }
) => {
const updated = store.updateRepo(args.repoId, args.updates)
if (updated) notifyReposChanged(mainWindow)
return updated
}
)
ipcMain.handle('repos:pickFolder', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory']
})
if (result.canceled || result.filePaths.length === 0) return null
return result.filePaths[0]
})
}
function notifyReposChanged(mainWindow: BrowserWindow): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('repos:changed')
}
}

13
src/main/ipc/settings.ts Normal file
View file

@ -0,0 +1,13 @@
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import type { GlobalSettings } from '../../shared/types'
export function registerSettingsHandlers(store: Store): void {
ipcMain.handle('settings:get', () => {
return store.getSettings()
})
ipcMain.handle('settings:set', (_event, args: Partial<GlobalSettings>) => {
return store.updateSettings(args)
})
}

11
src/main/ipc/shell.ts Normal file
View file

@ -0,0 +1,11 @@
import { ipcMain, shell } from 'electron'
export function registerShellHandlers(): void {
ipcMain.handle('shell:openPath', (_event, path: string) => {
shell.showItemInFolder(path)
})
ipcMain.handle('shell:openExternal', (_event, url: string) => {
return shell.openExternal(url)
})
}

175
src/main/ipc/worktrees.ts Normal file
View file

@ -0,0 +1,175 @@
import { BrowserWindow, ipcMain } from 'electron'
import { join, basename } from 'path'
import type { Store } from '../persistence'
import type { Worktree, WorktreeMeta } from '../../shared/types'
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
import { getGitUsername, getDefaultBranch } from '../git/repo'
import { loadHooks, runHook, hasHooksFile } from '../hooks'
export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store): void {
ipcMain.handle('worktrees:listAll', () => {
const repos = store.getRepos()
const allWorktrees: Worktree[] = []
for (const repo of repos) {
const gitWorktrees = listWorktrees(repo.path)
for (const gw of gitWorktrees) {
const worktreeId = `${repo.id}::${gw.path}`
const meta = store.getWorktreeMeta(worktreeId)
allWorktrees.push(mergeWorktree(repo.id, gw, meta))
}
}
return allWorktrees
})
ipcMain.handle('worktrees:list', (_event, args: { repoId: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) return []
const gitWorktrees = listWorktrees(repo.path)
return gitWorktrees.map((gw) => {
const worktreeId = `${repo.id}::${gw.path}`
const meta = store.getWorktreeMeta(worktreeId)
return mergeWorktree(repo.id, gw, meta)
})
})
ipcMain.handle(
'worktrees:create',
(_event, args: { repoId: string; name: string; baseBranch?: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) throw new Error(`Repo not found: ${args.repoId}`)
const settings = store.getSettings()
// Compute branch name with prefix
let branchName = args.name
if (settings.branchPrefix === 'git-username') {
const username = getGitUsername(repo.path)
if (username) {
branchName = `${username}/${args.name}`
}
} else if (settings.branchPrefix === 'custom' && settings.branchPrefixCustom) {
branchName = `${settings.branchPrefixCustom}/${args.name}`
}
// Compute worktree path
let worktreePath: string
if (settings.nestWorkspaces) {
const repoName = basename(repo.path).replace(/\.git$/, '')
worktreePath = join(settings.workspaceDir, repoName, args.name)
} else {
worktreePath = join(settings.workspaceDir, args.name)
}
// Determine base branch
const baseBranch = args.baseBranch || getDefaultBranch(repo.path)
addWorktree(repo.path, worktreePath, branchName, baseBranch)
// Re-list to get the freshly created worktree info
const gitWorktrees = listWorktrees(repo.path)
const created = gitWorktrees.find((gw) => gw.path === worktreePath)
if (!created) throw new Error('Worktree created but not found in listing')
const worktree = mergeWorktree(repo.id, created, undefined)
// Run setup hook asynchronously (don't block the UI)
const hooks = loadHooks(repo.path)
if (hooks?.scripts.setup) {
runHook('setup', worktreePath, repo.path).then((result) => {
if (!result.success) {
console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output)
}
})
}
notifyWorktreesChanged(mainWindow, repo.id)
return worktree
}
)
ipcMain.handle(
'worktrees:remove',
async (_event, args: { worktreeId: string; force?: boolean }) => {
const { repoId, worktreePath } = parseWorktreeId(args.worktreeId)
const repo = store.getRepo(repoId)
if (!repo) throw new Error(`Repo not found: ${repoId}`)
// Run archive hook before removal
const hooks = loadHooks(repo.path)
if (hooks?.scripts.archive) {
const result = await runHook('archive', worktreePath, repo.path)
if (!result.success) {
console.error(`[hooks] archive hook failed for ${worktreePath}:`, result.output)
}
}
removeWorktree(repo.path, worktreePath, args.force ?? false)
store.removeWorktreeMeta(args.worktreeId)
notifyWorktreesChanged(mainWindow, repoId)
}
)
ipcMain.handle(
'worktrees:updateMeta',
(_event, args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => {
const meta = store.setWorktreeMeta(args.worktreeId, args.updates)
const { repoId } = parseWorktreeId(args.worktreeId)
notifyWorktreesChanged(mainWindow, repoId)
return meta
}
)
ipcMain.handle('hooks:check', (_event, args: { repoId: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) return { hasHooks: false, hooks: null }
const has = hasHooksFile(repo.path)
const hooks = has ? loadHooks(repo.path) : null
return {
hasHooks: has,
hooks
}
})
}
function mergeWorktree(
repoId: string,
git: { path: string; head: string; branch: string; isBare: boolean },
meta: WorktreeMeta | undefined
): Worktree {
const branchShort = git.branch.replace(/^refs\/heads\//, '')
return {
id: `${repoId}::${git.path}`,
repoId,
path: git.path,
head: git.head,
branch: git.branch,
isBare: git.isBare,
displayName: meta?.displayName || branchShort || basename(git.path),
comment: meta?.comment || '',
linkedIssue: meta?.linkedIssue ?? null,
linkedPR: meta?.linkedPR ?? null,
isArchived: meta?.isArchived ?? false,
isUnread: meta?.isUnread ?? false,
sortOrder: meta?.sortOrder ?? 0
}
}
function parseWorktreeId(worktreeId: string): { repoId: string; worktreePath: string } {
const sepIdx = worktreeId.indexOf('::')
if (sepIdx === -1) throw new Error(`Invalid worktreeId: ${worktreeId}`)
return {
repoId: worktreeId.slice(0, sepIdx),
worktreePath: worktreeId.slice(sepIdx + 2)
}
}
function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('worktrees:changed', { repoId })
}
}

160
src/main/persistence.ts Normal file
View file

@ -0,0 +1,160 @@
import { app } from 'electron'
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
import { join, dirname } from 'path'
import { homedir } from 'os'
import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types'
import { getDefaultPersistedState } from '../shared/constants'
const DATA_FILE = join(app.getPath('userData'), 'orca-data.json')
export class Store {
private state: PersistedState
private writeTimer: ReturnType<typeof setTimeout> | null = null
constructor() {
this.state = this.load()
}
private load(): PersistedState {
try {
if (existsSync(DATA_FILE)) {
const raw = readFileSync(DATA_FILE, 'utf-8')
const parsed = JSON.parse(raw) as PersistedState
// Merge with defaults in case new fields were added
const defaults = getDefaultPersistedState(homedir())
return {
...defaults,
...parsed,
settings: { ...defaults.settings, ...parsed.settings },
ui: { ...defaults.ui, ...parsed.ui }
}
}
} catch (err) {
console.error('[persistence] Failed to load state, using defaults:', err)
}
return getDefaultPersistedState(homedir())
}
private scheduleSave(): void {
if (this.writeTimer) clearTimeout(this.writeTimer)
this.writeTimer = setTimeout(() => {
this.writeTimer = null
try {
const dir = dirname(DATA_FILE)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(DATA_FILE, JSON.stringify(this.state, null, 2), 'utf-8')
} catch (err) {
console.error('[persistence] Failed to write state:', err)
}
}, 300)
}
// ── Repos ──────────────────────────────────────────────────────────
getRepos(): Repo[] {
return this.state.repos
}
getRepo(id: string): Repo | undefined {
return this.state.repos.find((r) => r.id === id)
}
addRepo(repo: Repo): void {
this.state.repos.push(repo)
this.scheduleSave()
}
removeRepo(id: string): void {
this.state.repos = this.state.repos.filter((r) => r.id !== id)
// Clean up worktree meta for this repo
const prefix = `${id}::`
for (const key of Object.keys(this.state.worktreeMeta)) {
if (key.startsWith(prefix)) {
delete this.state.worktreeMeta[key]
}
}
this.scheduleSave()
}
updateRepo(id: string, updates: Partial<Pick<Repo, 'displayName' | 'badgeColor'>>): Repo | null {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) return null
Object.assign(repo, updates)
this.scheduleSave()
return repo
}
// ── Worktree Meta ──────────────────────────────────────────────────
getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined {
return this.state.worktreeMeta[worktreeId]
}
getAllWorktreeMeta(): Record<string, WorktreeMeta> {
return this.state.worktreeMeta
}
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): WorktreeMeta {
const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta()
const updated = { ...existing, ...meta }
this.state.worktreeMeta[worktreeId] = updated
this.scheduleSave()
return updated
}
removeWorktreeMeta(worktreeId: string): void {
delete this.state.worktreeMeta[worktreeId]
this.scheduleSave()
}
// ── Settings ───────────────────────────────────────────────────────
getSettings(): GlobalSettings {
return this.state.settings
}
updateSettings(updates: Partial<GlobalSettings>): GlobalSettings {
this.state.settings = { ...this.state.settings, ...updates }
this.scheduleSave()
return this.state.settings
}
// ── UI State ───────────────────────────────────────────────────────
getUI(): PersistedState['ui'] {
return this.state.ui
}
updateUI(updates: Partial<PersistedState['ui']>): void {
this.state.ui = { ...this.state.ui, ...updates }
this.scheduleSave()
}
// ── Flush (for shutdown) ───────────────────────────────────────────
flush(): void {
if (this.writeTimer) {
clearTimeout(this.writeTimer)
this.writeTimer = null
}
try {
const dir = dirname(DATA_FILE)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(DATA_FILE, JSON.stringify(this.state, null, 2), 'utf-8')
} catch (err) {
console.error('[persistence] Failed to flush state:', err)
}
}
}
function getDefaultWorktreeMeta(): WorktreeMeta {
return {
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
isArchived: false,
isUnread: false,
sortOrder: 0
}
}

View file

@ -1,10 +1,33 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import type {
Repo,
Worktree,
WorktreeMeta,
PRInfo,
IssueInfo,
GlobalSettings,
OrcaHooks
} from '../../shared/types'
interface WorktreeInfo {
path: string
head: string
branch: string
isBare: boolean
interface ReposApi {
list: () => Promise<Repo[]>
add: (args: { path: string }) => Promise<Repo>
remove: (args: { repoId: string }) => Promise<void>
update: (args: {
repoId: string
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor'>>
}) => Promise<Repo>
pickFolder: () => Promise<string | null>
onChanged: (callback: () => void) => () => void
}
interface WorktreesApi {
list: (args: { repoId: string }) => Promise<Worktree[]>
listAll: () => Promise<Worktree[]>
create: (args: { repoId: string; name: string; baseBranch?: string }) => Promise<Worktree>
remove: (args: { worktreeId: string; force?: boolean }) => Promise<void>
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
onChanged: (callback: (data: { repoId: string }) => void) => () => void
}
interface PtyApi {
@ -16,14 +39,34 @@ interface PtyApi {
onExit: (callback: (data: { id: string; code: number }) => void) => () => void
}
interface WorktreesApi {
list: (cwd: string) => Promise<WorktreeInfo[]>
getCurrent: () => Promise<WorktreeInfo[]>
interface GhApi {
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
listIssues: (args: { repoPath: string; limit?: number }) => Promise<IssueInfo[]>
}
interface SettingsApi {
get: () => Promise<GlobalSettings>
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
}
interface ShellApi {
openPath: (path: string) => Promise<void>
openExternal: (url: string) => Promise<void>
}
interface HooksApi {
check: (args: { repoId: string }) => Promise<{ hasHooks: boolean; hooks: OrcaHooks | null }>
}
interface Api {
pty: PtyApi
repos: ReposApi
worktrees: WorktreesApi
pty: PtyApi
gh: GhApi
settings: SettingsApi
shell: ShellApi
hooks: HooksApi
}
declare global {

View file

@ -3,6 +3,50 @@ import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {
repos: {
list: (): Promise<unknown[]> => ipcRenderer.invoke('repos:list'),
add: (args: { path: string }): Promise<unknown> => ipcRenderer.invoke('repos:add', args),
remove: (args: { repoId: string }): Promise<void> => ipcRenderer.invoke('repos:remove', args),
update: (args: { repoId: string; updates: Record<string, unknown> }): Promise<unknown> =>
ipcRenderer.invoke('repos:update', args),
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('repos:pickFolder'),
onChanged: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('repos:changed', listener)
return () => ipcRenderer.removeListener('repos:changed', listener)
}
},
worktrees: {
list: (args: { repoId: string }): Promise<unknown[]> =>
ipcRenderer.invoke('worktrees:list', args),
listAll: (): Promise<unknown[]> => ipcRenderer.invoke('worktrees:listAll'),
create: (args: { repoId: string; name: string; baseBranch?: string }): Promise<unknown> =>
ipcRenderer.invoke('worktrees:create', args),
remove: (args: { worktreeId: string; force?: boolean }): Promise<void> =>
ipcRenderer.invoke('worktrees:remove', args),
updateMeta: (args: {
worktreeId: string
updates: Record<string, unknown>
}): Promise<unknown> => ipcRenderer.invoke('worktrees:updateMeta', args),
onChanged: (callback: (data: { repoId: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) =>
callback(data)
ipcRenderer.on('worktrees:changed', listener)
return () => ipcRenderer.removeListener('worktrees:changed', listener)
}
},
pty: {
spawn: (opts: { cols: number; rows: number; cwd?: string }): Promise<{ id: string }> =>
ipcRenderer.invoke('pty:spawn', opts),
@ -32,15 +76,33 @@ const api = {
}
},
worktrees: {
list: (
cwd: string
): Promise<Array<{ path: string; head: string; branch: string; isBare: boolean }>> =>
ipcRenderer.invoke('worktrees:list', { cwd }),
gh: {
prForBranch: (args: { repoPath: string; branch: string }): Promise<unknown> =>
ipcRenderer.invoke('gh:prForBranch', args),
getCurrent: (): Promise<
Array<{ path: string; head: string; branch: string; isBare: boolean }>
> => ipcRenderer.invoke('worktrees:get-current')
issue: (args: { repoPath: string; number: number }): Promise<unknown> =>
ipcRenderer.invoke('gh:issue', args),
listIssues: (args: { repoPath: string; limit?: number }): Promise<unknown[]> =>
ipcRenderer.invoke('gh:listIssues', args)
},
settings: {
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),
set: (args: Record<string, unknown>): Promise<unknown> =>
ipcRenderer.invoke('settings:set', args)
},
shell: {
openPath: (path: string): Promise<void> => ipcRenderer.invoke('shell:openPath', path),
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('shell:openExternal', url)
},
hooks: {
check: (args: { repoId: string }): Promise<{ hasHooks: boolean; hooks: unknown }> =>
ipcRenderer.invoke('hooks:check', args)
}
}

View file

@ -3,11 +3,7 @@
<head>
<meta charset="UTF-8" />
<title>Orca</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; worker-src 'self' blob:; connect-src 'self' blob: data:"
/>
<!-- CSP is relaxed during development; electron-vite injects a stricter policy for production builds -->
</head>
<body>

View file

@ -1,30 +1,29 @@
import { useEffect } from 'react'
import { useAppStore } from './store'
import { useIpcEvents } from './hooks/useIpcEvents'
import Sidebar from './components/Sidebar'
import Terminal from './components/Terminal'
import Landing from './components/Landing'
import Settings from './components/Settings'
function App(): React.JSX.Element {
const toggleSidebar = useAppStore((s) => s.toggleSidebar)
const showTerminal = useAppStore((s) => s.showTerminal)
const setShowTerminal = useAppStore((s) => s.setShowTerminal)
const terminalTitle = useAppStore((s) => s.terminalTitle)
const activeView = useAppStore((s) => s.activeView)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const fetchRepos = useAppStore((s) => s.fetchRepos)
const fetchSettings = useAppStore((s) => s.fetchSettings)
// Enter key on landing page to spawn a new terminal
// Subscribe to IPC push events
useIpcEvents()
// Fetch initial data
useEffect(() => {
if (showTerminal) return
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Enter' && !e.repeat) {
e.preventDefault()
setShowTerminal(true)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [showTerminal, setShowTerminal])
fetchRepos()
fetchSettings()
}, [fetchRepos, fetchSettings])
return (
<div className="app-layout">
<div className="flex flex-col h-screen w-screen overflow-hidden">
<div className="titlebar">
<div className="titlebar-traffic-light-pad" />
<button className="sidebar-toggle" onClick={toggleSidebar} title="Toggle sidebar">
@ -42,14 +41,12 @@ function App(): React.JSX.Element {
<line x1="2" y1="12" x2="14" y2="12" />
</svg>
</button>
<div className="titlebar-title">
{showTerminal && terminalTitle ? terminalTitle : 'Orca'}
</div>
<div className="titlebar-title">Orca</div>
<div className="titlebar-spacer" />
</div>
<div className="content-area">
<div className="flex flex-row flex-1 overflow-hidden">
<Sidebar />
{showTerminal ? <Terminal /> : <Landing />}
{activeView === 'settings' ? <Settings /> : activeWorktreeId ? <Terminal /> : <Landing />}
</div>
</div>
)

View file

@ -1,16 +1,26 @@
import { useAppStore } from '../store'
export default function Landing(): React.JSX.Element {
const setShowTerminal = useAppStore((s) => s.setShowTerminal)
const repos = useAppStore((s) => s.repos)
const addRepo = useAppStore((s) => s.addRepo)
return (
<div className="landing">
<div className="landing-content">
<h1 className="landing-title">Orca</h1>
<button className="landing-action" onClick={() => setShowTerminal(true)}>
New Terminal
</button>
<span className="landing-hint">or press Enter</span>
<div className="flex-1 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<h1 className="text-5xl font-bold text-muted-foreground tracking-tight">Orca</h1>
{repos.length === 0 ? (
<>
<p className="text-sm text-muted-foreground">Get started by adding a repository</p>
<button
className="bg-secondary border border-border text-foreground font-mono text-sm px-6 py-2 rounded-md cursor-pointer hover:bg-accent transition-colors"
onClick={addRepo}
>
Add Repository
</button>
</>
) : (
<p className="text-sm text-muted-foreground">Select a worktree from the sidebar</p>
)}
</div>
</div>
)

View file

@ -0,0 +1,373 @@
import { useEffect, useState, useCallback } from 'react'
import { useAppStore } from '../store'
import { REPO_COLORS } from '../../../shared/constants'
import { ScrollArea } from './ui/scroll-area'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { Label } from './ui/label'
import { Separator } from './ui/separator'
import { ArrowLeft, FolderOpen, Minus, Plus, Trash2 } from 'lucide-react'
function Settings(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const setActiveView = useAppStore((s) => s.setActiveView)
const repos = useAppStore((s) => s.repos)
const updateRepo = useAppStore((s) => s.updateRepo)
const removeRepo = useAppStore((s) => s.removeRepo)
const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null)
const [repoHooksMap, setRepoHooksMap] = useState<Record<string, boolean>>({})
useEffect(() => {
fetchSettings()
}, [fetchSettings])
// Check which repos have orca.yaml hooks
useEffect(() => {
const checkHooks = async () => {
const map: Record<string, boolean> = {}
for (const repo of repos) {
try {
const result = await window.api.hooks.check({ repoId: repo.id })
map[repo.id] = result.hasHooks
} catch {
map[repo.id] = false
}
}
setRepoHooksMap(map)
}
if (repos.length > 0) checkHooks()
}, [repos])
// Apply theme immediately
const applyTheme = useCallback((theme: 'system' | 'dark' | 'light') => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else if (theme === 'light') {
root.classList.remove('dark')
} else {
// system
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (prefersDark) {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
}, [])
const handleBrowseWorkspace = async () => {
const path = await window.api.repos.pickFolder()
if (path) {
updateSettings({ workspaceDir: path })
}
}
const handleRemoveRepo = (repoId: string) => {
if (confirmingRemove === repoId) {
removeRepo(repoId)
setConfirmingRemove(null)
} else {
setConfirmingRemove(repoId)
}
}
if (!settings) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading settings...
</div>
)
}
return (
<div className="flex-1 flex flex-col overflow-hidden bg-background">
{/* Header */}
<div className="flex items-center gap-3 px-6 py-4 border-b">
<Button variant="ghost" size="icon-sm" onClick={() => setActiveView('terminal')}>
<ArrowLeft className="size-4" />
</Button>
<h1 className="text-lg font-semibold">Settings</h1>
</div>
{/* Content */}
<ScrollArea className="flex-1">
<div className="max-w-2xl px-8 py-6 space-y-8">
{/* ── Workspace ────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Workspace</h2>
{/* Workspace Directory */}
<div className="space-y-2">
<Label className="text-sm">Workspace Directory</Label>
<p className="text-xs text-muted-foreground">
Root directory where worktree folders are created.
</p>
<div className="flex gap-2">
<Input
value={settings.workspaceDir}
onChange={(e) => updateSettings({ workspaceDir: e.target.value })}
className="flex-1 font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={handleBrowseWorkspace}
className="gap-1.5 shrink-0"
>
<FolderOpen className="size-3.5" />
Browse
</Button>
</div>
</div>
{/* Nest Workspaces */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm">Nest Workspaces</Label>
<p className="text-xs text-muted-foreground">
Create worktrees inside a repo-named subfolder.
</p>
</div>
<button
role="switch"
aria-checked={settings.nestWorkspaces}
onClick={() => updateSettings({ nestWorkspaces: !settings.nestWorkspaces })}
className={`
relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full
border border-transparent transition-colors
${settings.nestWorkspaces ? 'bg-foreground' : 'bg-muted-foreground/30'}
`}
>
<span
className={`
pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform
${settings.nestWorkspaces ? 'translate-x-4' : 'translate-x-0.5'}
`}
/>
</button>
</div>
</section>
<Separator />
{/* ── Branch Prefix ────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Branch Naming</h2>
<div className="space-y-2">
<Label className="text-sm">Branch Name Prefix</Label>
<p className="text-xs text-muted-foreground">
Prefix added to branch names when creating worktrees.
</p>
<div className="flex gap-1 rounded-md border p-1 w-fit">
{(['git-username', 'custom', 'none'] as const).map((option) => (
<button
key={option}
onClick={() => updateSettings({ branchPrefix: option })}
className={`
px-3 py-1 text-sm rounded-sm transition-colors
${
settings.branchPrefix === option
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
}
`}
>
{option === 'git-username'
? 'Git Username'
: option === 'custom'
? 'Custom'
: 'None'}
</button>
))}
</div>
{settings.branchPrefix === 'custom' && (
<Input
value={settings.branchPrefixCustom}
onChange={(e) => updateSettings({ branchPrefixCustom: e.target.value })}
placeholder="e.g. feature/"
className="max-w-xs mt-2"
/>
)}
</div>
</section>
<Separator />
{/* ── Appearance ───────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Appearance</h2>
{/* Theme */}
<div className="space-y-2">
<Label className="text-sm">Theme</Label>
<div className="flex gap-1 rounded-md border p-1 w-fit">
{(['system', 'dark', 'light'] as const).map((option) => (
<button
key={option}
onClick={() => {
updateSettings({ theme: option })
applyTheme(option)
}}
className={`
px-3 py-1 text-sm rounded-sm transition-colors capitalize
${
settings.theme === option
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
}
`}
>
{option}
</button>
))}
</div>
</div>
</section>
<Separator />
{/* ── Terminal ─────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Terminal</h2>
{/* Font Size */}
<div className="space-y-2">
<Label className="text-sm">Font Size</Label>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
onClick={() => {
const next = Math.max(10, settings.terminalFontSize - 1)
updateSettings({ terminalFontSize: next })
}}
disabled={settings.terminalFontSize <= 10}
>
<Minus className="size-3" />
</Button>
<Input
type="number"
min={10}
max={24}
value={settings.terminalFontSize}
onChange={(e) => {
const val = parseInt(e.target.value, 10)
if (!isNaN(val) && val >= 10 && val <= 24) {
updateSettings({ terminalFontSize: val })
}
}}
className="w-16 text-center tabular-nums"
/>
<Button
variant="outline"
size="icon-sm"
onClick={() => {
const next = Math.min(24, settings.terminalFontSize + 1)
updateSettings({ terminalFontSize: next })
}}
disabled={settings.terminalFontSize >= 24}
>
<Plus className="size-3" />
</Button>
<span className="text-xs text-muted-foreground">px</span>
</div>
</div>
{/* Font Family */}
<div className="space-y-2">
<Label className="text-sm">Font Family</Label>
<Input
value={settings.terminalFontFamily}
onChange={(e) => updateSettings({ terminalFontFamily: e.target.value })}
placeholder="SF Mono"
className="max-w-xs"
/>
</div>
</section>
<Separator />
{/* ── Repos ────────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Repositories</h2>
<p className="text-xs text-muted-foreground">
Manage display names and badge colors for your repositories.
</p>
{repos.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">No repositories added yet.</p>
) : (
<div className="space-y-3">
{repos.map((repo) => (
<div key={repo.id} className="flex items-center gap-4 rounded-lg border p-3">
{/* Color picker */}
<div className="flex gap-1.5 shrink-0">
{REPO_COLORS.map((color) => (
<button
key={color}
onClick={() => updateRepo(repo.id, { badgeColor: color })}
className={`
size-5 rounded-full transition-all
${
repo.badgeColor === color
? 'ring-2 ring-foreground ring-offset-2 ring-offset-background'
: 'hover:ring-1 hover:ring-muted-foreground hover:ring-offset-1 hover:ring-offset-background'
}
`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
{/* Display name */}
<Input
value={repo.displayName}
onChange={(e) => updateRepo(repo.id, { displayName: e.target.value })}
className="flex-1 h-8 text-sm"
/>
{/* Hooks indicator */}
{repoHooksMap[repo.id] && (
<span
className="shrink-0 rounded-md bg-accent px-2 py-0.5 text-[10px] font-medium text-accent-foreground"
title="This repo has an orca.yaml with lifecycle hooks"
>
hooks
</span>
)}
{/* Remove */}
<Button
variant={confirmingRemove === repo.id ? 'destructive' : 'ghost'}
size="icon-sm"
onClick={() => handleRemoveRepo(repo.id)}
onBlur={() => setConfirmingRemove(null)}
title={
confirmingRemove === repo.id
? 'Click again to confirm'
: 'Remove repository'
}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
)}
</section>
{/* Bottom spacing */}
<div className="h-8" />
</div>
</ScrollArea>
</div>
)
}
export default Settings

View file

@ -1,42 +1 @@
import { useEffect } from 'react'
import { useAppStore } from '../store'
function branchDisplayName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
function pathBasename(p: string): string {
return p.split('/').pop() || p
}
export default function Sidebar(): React.JSX.Element {
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
const worktrees = useAppStore((s) => s.worktrees)
const activeWorktree = useAppStore((s) => s.activeWorktree)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
useEffect(() => {
fetchWorktrees()
}, [fetchWorktrees])
return (
<div className={`sidebar ${sidebarOpen ? '' : 'collapsed'}`}>
<div className="sidebar-header">Worktrees</div>
<ul className="worktree-list">
{worktrees.map((wt) => (
<li
key={wt.path}
className={`worktree-item ${activeWorktree === wt.path ? 'active' : ''}`}
onClick={() => setActiveWorktree(wt.path)}
>
<span className="worktree-branch">
{wt.isBare ? '(bare)' : branchDisplayName(wt.branch)}
</span>
<span className="worktree-path">{pathBasename(wt.path)}</span>
</li>
))}
</ul>
</div>
)
}
export { default } from './sidebar/index'

View file

@ -0,0 +1,144 @@
import { useCallback, useMemo } from 'react'
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
horizontalListSortingStrategy,
arrayMove
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { X, Plus } from 'lucide-react'
import type { TerminalTab } from '../../../shared/types'
interface SortableTabProps {
tab: TerminalTab
isActive: boolean
onActivate: (tabId: string) => void
onClose: (tabId: string) => void
}
function SortableTab({ tab, isActive, onActivate, onClose }: SortableTabProps): React.JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.8 : 1
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
isActive
? 'bg-background text-foreground border-b-transparent'
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
onPointerDown={(e) => {
// Allow dnd-kit to handle drag, but also activate tab
onActivate(tab.id)
// Forward to dnd-kit listeners
listeners?.onPointerDown?.(e)
}}
>
<span className="truncate max-w-[140px] mr-1.5">{tab.title}</span>
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
isActive
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onClose(tab.id)
}}
>
<X className="w-3 h-3" />
</button>
</div>
)
}
interface TabBarProps {
tabs: TerminalTab[]
activeTabId: string | null
worktreeId: string
onActivate: (tabId: string) => void
onClose: (tabId: string) => void
onReorder: (worktreeId: string, tabIds: string[]) => void
onNewTab: () => void
}
export default function TabBar({
tabs,
activeTabId,
worktreeId,
onActivate,
onClose,
onReorder,
onNewTab
}: TabBarProps): React.JSX.Element {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 }
})
)
const tabIds = useMemo(() => tabs.map((t) => t.id), [tabs])
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = tabIds.indexOf(active.id as string)
const newIndex = tabIds.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return
const newOrder = arrayMove(tabIds, oldIndex, newIndex)
onReorder(worktreeId, newOrder)
},
[tabIds, worktreeId, onReorder]
)
return (
<div className="flex items-stretch h-9 bg-card border-b border-border overflow-hidden shrink-0">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
<div className="flex items-stretch overflow-x-auto">
{tabs.map((tab) => (
<SortableTab
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
onActivate={onActivate}
onClose={onClose}
/>
))}
</div>
</SortableContext>
</DndContext>
<button
className="flex items-center justify-center w-9 h-full shrink-0 text-muted-foreground hover:text-foreground hover:bg-accent/50"
onClick={onNewTab}
title="New terminal (Cmd+T)"
>
<Plus className="w-4 h-4" />
</button>
</div>
)
}

View file

@ -1,374 +1,163 @@
import { useEffect, useRef } from 'react'
import { Restty, getBuiltinTheme } from 'restty'
import { useEffect, useCallback, useRef } from 'react'
import { useAppStore } from '../store'
type PtyTransport = {
connect: (options: {
url: string
cols?: number
rows?: number
callbacks: {
onConnect?: () => void
onDisconnect?: () => void
onData?: (data: string) => void
onStatus?: (shell: string) => void
onError?: (message: string, errors?: string[]) => void
onExit?: (code: number) => void
}
}) => void | Promise<void>
disconnect: () => void
sendInput: (data: string) => boolean
resize: (
cols: number,
rows: number,
meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number }
) => boolean
isConnected: () => boolean
destroy?: () => void | Promise<void>
}
// OSC 0/1/2 title regex: \x1b]N;title(\x07|\x1b\\)
// Handles both BEL and ST terminators, and partial sequences across chunks
const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
function extractLastOscTitle(data: string): string | null {
let last: string | null = null
let m: RegExpExecArray | null
OSC_TITLE_RE.lastIndex = 0
while ((m = OSC_TITLE_RE.exec(data)) !== null) {
last = m[2]
}
return last
}
function createIpcPtyTransport(
cwd?: string,
onPtyExit?: () => void,
onTitleChange?: (title: string) => void
): PtyTransport {
let connected = false
let ptyId: string | null = null
let storedCallbacks: {
onConnect?: () => void
onDisconnect?: () => void
onData?: (data: string) => void
onStatus?: (shell: string) => void
onError?: (message: string, errors?: string[]) => void
onExit?: (code: number) => void
} = {}
let unsubData: (() => void) | null = null
let unsubExit: (() => void) | null = null
return {
async connect(options) {
storedCallbacks = options.callbacks
try {
const result = await window.api.pty.spawn({
cols: options.cols ?? 80,
rows: options.rows ?? 24,
cwd
})
ptyId = result.id
connected = true
unsubData = window.api.pty.onData((payload) => {
if (payload.id === ptyId) {
storedCallbacks.onData?.(payload.data)
if (onTitleChange) {
const title = extractLastOscTitle(payload.data)
if (title !== null) onTitleChange(title)
}
}
})
unsubExit = window.api.pty.onExit((payload) => {
if (payload.id === ptyId) {
connected = false
storedCallbacks.onExit?.(payload.code)
storedCallbacks.onDisconnect?.()
onPtyExit?.()
}
})
storedCallbacks.onConnect?.()
storedCallbacks.onStatus?.('shell')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
storedCallbacks.onError?.(msg)
}
},
disconnect() {
if (ptyId) {
window.api.pty.kill(ptyId)
connected = false
ptyId = null
unsubData?.()
unsubExit?.()
unsubData = null
unsubExit = null
storedCallbacks.onDisconnect?.()
}
},
sendInput(data: string): boolean {
if (!connected || !ptyId) return false
window.api.pty.write(ptyId, data)
return true
},
resize(cols: number, rows: number): boolean {
if (!connected || !ptyId) return false
window.api.pty.resize(ptyId, cols, rows)
return true
},
isConnected() {
return connected
},
destroy() {
this.disconnect()
}
}
}
import TabBar from './TabBar'
import TerminalPane from './TerminalPane'
export default function Terminal(): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const resttyRef = useRef<Restty | null>(null)
const activeWorktree = useAppStore((s) => s.activeWorktree)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)!
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const activeTabId = useAppStore((s) => s.activeTabId)
const createTab = useAppStore((s) => s.createTab)
const closeTab = useAppStore((s) => s.closeTab)
const setActiveTab = useAppStore((s) => s.setActiveTab)
const reorderTabs = useAppStore((s) => s.reorderTabs)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const tabs = tabsByWorktree[activeWorktreeId] ?? []
const prevTabCountRef = useRef(tabs.length)
const tabBarRef = useRef<HTMLDivElement>(null)
// Find the active worktree to get its path
const activeWorktree = activeWorktreeId
? Object.values(worktreesByRepo)
.flat()
.find((w) => w.id === activeWorktreeId)
: null
const cwd = activeWorktree?.path
// Auto-create first tab when worktree activates
useEffect(() => {
const container = containerRef.current
if (!container) return
const cwd = activeWorktree ?? undefined
// Map pane id -> its associated pty transport exit handler
const paneCloseQueue: number[] = []
let closeTimer: ReturnType<typeof setTimeout> | null = null
function scheduleClose(paneId: number): void {
paneCloseQueue.push(paneId)
if (closeTimer) return
// Batch close on next tick to avoid issues during restty callbacks
closeTimer = setTimeout(() => {
closeTimer = null
const restty = resttyRef.current
if (!restty) return
while (paneCloseQueue.length > 0) {
const id = paneCloseQueue.shift()!
const panes = restty.getPanes()
if (panes.length <= 1) {
// Last pane — go to landing
useAppStore.getState().setShowTerminal(false)
return
}
restty.closePane(id)
}
}, 0)
if (activeWorktreeId && tabs.length === 0) {
createTab(activeWorktreeId)
}
}, [activeWorktreeId, tabs.length, createTab])
// Track per-pane titles
const paneTitles = new Map<number, string>()
let currentActivePaneId: number | null = null
function syncTitleToStore(): void {
if (currentActivePaneId !== null) {
const title = paneTitles.get(currentActivePaneId) ?? ''
useAppStore.getState().setTerminalTitle(title)
} else {
useAppStore.getState().setTerminalTitle('')
}
// Ensure activeTabId is valid
useEffect(() => {
if (tabs.length > 0 && (!activeTabId || !tabs.find((t) => t.id === activeTabId))) {
setActiveTab(tabs[0].id)
}
}, [tabs, activeTabId, setActiveTab])
const restty = new Restty({
root: container,
createInitialPane: false,
autoInit: false,
shortcuts: { enabled: true },
appOptions: ({ id }) => {
const onPtyExit = (): void => {
scheduleClose(id)
}
const onTitleChange = (title: string): void => {
paneTitles.set(id, title)
if (id === currentActivePaneId) {
useAppStore.getState().setTerminalTitle(title)
}
}
return {
renderer: 'webgpu',
fontSize: 14,
fontSizeMode: 'em',
alphaBlending: 'native',
ptyTransport: createIpcPtyTransport(cwd, onPtyExit, onTitleChange) as never,
fontSources: [
{
type: 'local' as const,
label: 'SF Mono',
matchers: ['sf mono', 'sfmono-regular'],
required: true
},
{
type: 'local' as const,
label: 'Menlo',
matchers: ['menlo', 'menlo regular']
}
]
}
},
onPaneCreated: async (pane) => {
await pane.app.init()
const theme = getBuiltinTheme('Aizen Dark')
if (theme) pane.app.applyTheme(theme, 'Aizen Dark')
pane.app.updateSize(true)
pane.app.connectPty('')
pane.canvas.focus({ preventScroll: true })
},
onPaneClosed: (pane) => {
paneTitles.delete(pane.id)
},
onActivePaneChange: (pane) => {
currentActivePaneId = pane?.id ?? null
syncTitleToStore()
}
})
// Animate tab bar height with grid transition
useEffect(() => {
const el = tabBarRef.current
if (!el) return
restty.createInitialPane({ focus: true })
resttyRef.current = restty
// --- Pane zoom state ---
let zoomedPaneId: number | null = null
const savedStyles: { el: HTMLElement; prop: string; prev: string }[] = []
function saveAndSet(el: HTMLElement, prop: string, value: string): void {
savedStyles.push({
el,
prop,
prev: (el.style as unknown as Record<string, string>)[prop] ?? ''
})
;(el.style as unknown as Record<string, string>)[prop] = value
const showBar = tabs.length >= 2
if (showBar) {
el.style.gridTemplateRows = '1fr'
} else {
el.style.gridTemplateRows = '0fr'
}
prevTabCountRef.current = tabs.length
}, [tabs.length])
function unzoom(): void {
if (zoomedPaneId === null) return
for (const entry of savedStyles) {
;(entry.el.style as unknown as Record<string, string>)[entry.prop] = entry.prev
}
savedStyles.length = 0
zoomedPaneId = null
requestAnimationFrame(() => {
for (const p of restty.getPanes()) {
p.app.updateSize(true)
}
})
}
const handleNewTab = useCallback(() => {
createTab(activeWorktreeId)
}, [activeWorktreeId, createTab])
function togglePaneZoom(): void {
const panes = restty.getPanes()
if (zoomedPaneId !== null) {
unzoom()
const handleCloseTab = useCallback(
(tabId: string) => {
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
if (currentTabs.length <= 1) {
// Last tab - deactivate worktree
closeTab(tabId)
setActiveWorktree(null)
return
}
if (panes.length <= 1) return
const active = restty.getActivePane() ?? panes[0]
if (!active) return
let current: HTMLElement = active.container
saveAndSet(current, 'flex', '1 1 100%')
while (current.parentElement) {
const parent = current.parentElement
for (const sibling of Array.from(parent.children) as HTMLElement[]) {
if (sibling === current) continue
saveAndSet(sibling, 'display', 'none')
}
if (parent === container) break
saveAndSet(parent, 'flex', '1 1 100%')
current = parent
// If closing the active tab, switch to a neighbor
if (tabId === useAppStore.getState().activeTabId) {
const idx = currentTabs.findIndex((t) => t.id === tabId)
const nextTab = currentTabs[idx + 1] ?? currentTabs[idx - 1]
if (nextTab) setActiveTab(nextTab.id)
}
closeTab(tabId)
},
[activeWorktreeId, closeTab, setActiveTab, setActiveWorktree]
)
zoomedPaneId = active.id
requestAnimationFrame(() => {
active.app.updateSize(true)
})
}
function closeActivePane(): void {
const panes = restty.getPanes()
const active = restty.getActivePane() ?? panes[0]
if (!active) return
// If zoomed and closing the zoomed pane, unzoom first
if (zoomedPaneId === active.id) {
unzoom()
}
if (panes.length <= 1) {
// Last pane — disconnect PTY and go to landing
active.app.disconnectPty()
useAppStore.getState().setShowTerminal(false)
return
}
// Disconnect PTY then close the pane
active.app.disconnectPty()
restty.closePane(active.id)
}
const handlePtyExit = useCallback(
(tabId: string) => {
handleCloseTab(tabId)
},
[handleCloseTab]
)
// Keyboard shortcuts
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
// Cmd+K: clear screen
if (e.metaKey && e.key === 'k' && !e.shiftKey && !e.repeat) {
// Cmd+T - new tab
if (e.metaKey && e.key === 't' && !e.shiftKey && !e.repeat) {
e.preventDefault()
const pane = restty.getActivePane() ?? restty.getPanes()[0]
if (pane) {
pane.app.clearScreen()
}
handleNewTab()
return
}
// Cmd+W: close active pane (not the window)
// Cmd+W - close active tab
if (e.metaKey && e.key === 'w' && !e.shiftKey && !e.repeat) {
e.preventDefault()
closeActivePane()
return
}
// Cmd+] / Cmd+[: cycle through panes
if (e.metaKey && !e.shiftKey && (e.key === ']' || e.key === '[') && !e.repeat) {
const panes = restty.getPanes()
if (panes.length > 1) {
e.preventDefault()
const active = restty.getActivePane()
const idx = active ? panes.findIndex((p) => p.id === active.id) : -1
const dir = e.key === ']' ? 1 : -1
const next = panes[(idx + dir + panes.length) % panes.length]
if (next) {
next.canvas.focus({ preventScroll: true })
}
const currentActiveTabId = useAppStore.getState().activeTabId
if (currentActiveTabId) {
handleCloseTab(currentActiveTabId)
}
return
}
// Cmd+Shift+Enter: toggle pane zoom
if (e.metaKey && e.shiftKey && e.key === 'Enter' && !e.repeat) {
e.preventDefault()
togglePaneZoom()
// Cmd+Shift+] and Cmd+Shift+[ - switch tabs
if (e.metaKey && e.shiftKey && (e.key === ']' || e.key === '[') && !e.repeat) {
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
if (currentTabs.length > 1) {
e.preventDefault()
const currentId = useAppStore.getState().activeTabId
const idx = currentTabs.findIndex((t) => t.id === currentId)
const dir = e.key === ']' ? 1 : -1
const next = currentTabs[(idx + dir + currentTabs.length) % currentTabs.length]
if (next) setActiveTab(next.id)
}
return
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [activeWorktreeId, handleNewTab, handleCloseTab, setActiveTab])
return () => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
if (closeTimer) clearTimeout(closeTimer)
restty.destroy()
resttyRef.current = null
}
}, [activeWorktree])
return (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Animated tab bar container using CSS grid for smooth height animation */}
<div
ref={tabBarRef}
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
style={{ gridTemplateRows: tabs.length >= 2 ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
<TabBar
tabs={tabs}
activeTabId={activeTabId}
worktreeId={activeWorktreeId}
onActivate={setActiveTab}
onClose={handleCloseTab}
onReorder={reorderTabs}
onNewTab={handleNewTab}
/>
</div>
</div>
return <div className="terminal-container" ref={containerRef} />
{/* Terminal panes container */}
<div className="relative flex-1 overflow-hidden">
{tabs.map((tab) => (
<TerminalPane
key={tab.id}
tabId={tab.id}
cwd={cwd}
isActive={tab.id === activeTabId}
onPtyExit={() => handlePtyExit(tab.id)}
/>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,260 @@
import { useEffect, useRef } from 'react'
import { Restty, getBuiltinTheme } from 'restty'
import { useAppStore } from '../store'
type PtyTransport = {
connect: (options: {
url: string
cols?: number
rows?: number
callbacks: {
onConnect?: () => void
onDisconnect?: () => void
onData?: (data: string) => void
onStatus?: (shell: string) => void
onError?: (message: string, errors?: string[]) => void
onExit?: (code: number) => void
}
}) => void | Promise<void>
disconnect: () => void
sendInput: (data: string) => boolean
resize: (
cols: number,
rows: number,
meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number }
) => boolean
isConnected: () => boolean
destroy?: () => void | Promise<void>
}
const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
function extractLastOscTitle(data: string): string | null {
let last: string | null = null
let m: RegExpExecArray | null
OSC_TITLE_RE.lastIndex = 0
while ((m = OSC_TITLE_RE.exec(data)) !== null) {
last = m[2]
}
return last
}
function createIpcPtyTransport(
cwd?: string,
onPtyExit?: () => void,
onTitleChange?: (title: string) => void,
onPtySpawn?: (ptyId: string) => void
): PtyTransport {
let connected = false
let ptyId: string | null = null
let storedCallbacks: {
onConnect?: () => void
onDisconnect?: () => void
onData?: (data: string) => void
onStatus?: (shell: string) => void
onError?: (message: string, errors?: string[]) => void
onExit?: (code: number) => void
} = {}
let unsubData: (() => void) | null = null
let unsubExit: (() => void) | null = null
return {
async connect(options) {
storedCallbacks = options.callbacks
try {
const result = await window.api.pty.spawn({
cols: options.cols ?? 80,
rows: options.rows ?? 24,
cwd
})
ptyId = result.id
connected = true
onPtySpawn?.(result.id)
unsubData = window.api.pty.onData((payload) => {
if (payload.id === ptyId) {
storedCallbacks.onData?.(payload.data)
if (onTitleChange) {
const title = extractLastOscTitle(payload.data)
if (title !== null) onTitleChange(title)
}
}
})
unsubExit = window.api.pty.onExit((payload) => {
if (payload.id === ptyId) {
connected = false
storedCallbacks.onExit?.(payload.code)
storedCallbacks.onDisconnect?.()
onPtyExit?.()
}
})
storedCallbacks.onConnect?.()
storedCallbacks.onStatus?.('shell')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
storedCallbacks.onError?.(msg)
}
},
disconnect() {
if (ptyId) {
window.api.pty.kill(ptyId)
connected = false
ptyId = null
unsubData?.()
unsubExit?.()
unsubData = null
unsubExit = null
storedCallbacks.onDisconnect?.()
}
},
sendInput(data: string): boolean {
if (!connected || !ptyId) return false
window.api.pty.write(ptyId, data)
return true
},
resize(cols: number, rows: number): boolean {
if (!connected || !ptyId) return false
window.api.pty.resize(ptyId, cols, rows)
return true
},
isConnected() {
return connected
},
destroy() {
this.disconnect()
}
}
}
interface TerminalPaneProps {
tabId: string
cwd?: string
isActive: boolean
onPtyExit: () => void
}
export default function TerminalPane({
tabId,
cwd,
isActive,
onPtyExit
}: TerminalPaneProps): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const resttyRef = useRef<Restty | null>(null)
const wasActiveRef = useRef(isActive)
const updateTabTitle = useAppStore((s) => s.updateTabTitle)
const updateTabPtyId = useAppStore((s) => s.updateTabPtyId)
// Use a ref so the Restty closure always calls the latest onPtyExit
const onPtyExitRef = useRef(onPtyExit)
onPtyExitRef.current = onPtyExit
// Initialize Restty instance once
useEffect(() => {
const container = containerRef.current
if (!container) return
const onTitleChange = (title: string): void => {
updateTabTitle(tabId, title)
}
const onPtySpawn = (ptyId: string): void => {
updateTabPtyId(tabId, ptyId)
}
const restty = new Restty({
root: container,
createInitialPane: false,
autoInit: false,
shortcuts: { enabled: true },
appOptions: ({ id }) => {
const onExit = (): void => {
// Schedule close via parent
const panes = restty.getPanes()
if (panes.length <= 1) {
onPtyExitRef.current()
return
}
restty.closePane(id)
}
return {
renderer: 'webgpu',
fontSize: 14,
fontSizeMode: 'em',
alphaBlending: 'native',
ptyTransport: createIpcPtyTransport(cwd, onExit, onTitleChange, onPtySpawn) as never,
fontSources: [
{
type: 'local' as const,
label: 'SF Mono',
matchers: ['sf mono', 'sfmono-regular'],
required: true
},
{
type: 'local' as const,
label: 'Menlo',
matchers: ['menlo', 'menlo regular']
}
]
}
},
onPaneCreated: async (pane) => {
await pane.app.init()
const theme = getBuiltinTheme('Aizen Dark')
if (theme) pane.app.applyTheme(theme, 'Aizen Dark')
pane.app.updateSize(true)
pane.app.connectPty('')
pane.canvas.focus({ preventScroll: true })
},
onPaneClosed: () => {},
onActivePaneChange: () => {}
})
restty.createInitialPane({ focus: isActive })
resttyRef.current = restty
return () => {
restty.destroy()
resttyRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabId, cwd])
// Handle focus and resize when tab becomes active
useEffect(() => {
const restty = resttyRef.current
if (!restty) return
if (isActive && !wasActiveRef.current) {
// Tab just became active - focus and resize
requestAnimationFrame(() => {
const panes = restty.getPanes()
for (const p of panes) {
p.app.updateSize(true)
}
const active = restty.getActivePane() ?? panes[0]
if (active) {
active.canvas.focus({ preventScroll: true })
}
})
}
wasActiveRef.current = isActive
}, [isActive])
return (
<div
ref={containerRef}
className="absolute inset-0"
style={{ display: isActive ? 'block' : 'none' }}
/>
)
}

View file

@ -0,0 +1,171 @@
import React, { useState, useCallback } from 'react'
import { useAppStore } from '@/store'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem
} from '@/components/ui/select'
const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const closeModal = useAppStore((s) => s.closeModal)
const repos = useAppStore((s) => s.repos)
const createWorktree = useAppStore((s) => s.createWorktree)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const [repoId, setRepoId] = useState<string>('')
const [name, setName] = useState('')
const [linkedIssue, setLinkedIssue] = useState('')
const [comment, setComment] = useState('')
const [creating, setCreating] = useState(false)
const isOpen = activeModal === 'create-worktree'
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
closeModal()
setRepoId('')
setName('')
setLinkedIssue('')
setComment('')
}
},
[closeModal]
)
const handleCreate = useCallback(async () => {
if (!repoId || !name.trim()) return
setCreating(true)
try {
const wt = await createWorktree(repoId, name.trim())
if (wt) {
const metaUpdates: Record<string, unknown> = {}
if (linkedIssue.trim()) {
const num = parseInt(linkedIssue.trim(), 10)
if (!isNaN(num)) (metaUpdates as { linkedIssue: number }).linkedIssue = num
}
if (comment.trim()) {
;(metaUpdates as { comment: string }).comment = comment.trim()
}
if (Object.keys(metaUpdates).length > 0) {
await updateWorktreeMeta(wt.id, metaUpdates as { linkedIssue?: number; comment?: string })
}
}
handleOpenChange(false)
} finally {
setCreating(false)
}
}, [repoId, name, linkedIssue, comment, createWorktree, updateWorktreeMeta, handleOpenChange])
// Auto-select first repo when opening
React.useEffect(() => {
if (isOpen && repos.length > 0 && !repoId) {
setRepoId(repos[0].id)
}
}, [isOpen, repos, repoId])
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">New Worktree</DialogTitle>
<DialogDescription className="text-xs">
Create a new git worktree. The branch name will inherit from the name you provide.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{/* Repo selector */}
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Repository</label>
<Select value={repoId} onValueChange={setRepoId}>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select repo..." />
</SelectTrigger>
<SelectContent>
{repos.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Name */}
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Name</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="feature/my-feature"
className="h-8 text-xs"
autoFocus
/>
</div>
{/* Link GH Issue */}
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
Link GH Issue <span className="text-muted-foreground/50">(optional)</span>
</label>
<Input
value={linkedIssue}
onChange={(e) => setLinkedIssue(e.target.value)}
placeholder="Issue number, e.g. 42"
className="h-8 text-xs"
/>
</div>
{/* Comment */}
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
Comment <span className="text-muted-foreground/50">(optional)</span>
</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Notes about this worktree..."
rows={2}
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenChange(false)}
className="text-xs"
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={!repoId || !name.trim() || creating}
className="text-xs"
>
{creating ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
})
export default AddWorktreeDialog

View file

@ -0,0 +1,57 @@
import React from 'react'
import { useAppStore } from '@/store'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem
} from '@/components/ui/select'
const GroupControls = React.memo(function GroupControls() {
const groupBy = useAppStore((s) => s.groupBy)
const setGroupBy = useAppStore((s) => s.setGroupBy)
const sortBy = useAppStore((s) => s.sortBy)
const setSortBy = useAppStore((s) => s.setSortBy)
return (
<div className="flex items-center justify-between px-2 pb-1.5">
<ToggleGroup
type="single"
value={groupBy}
onValueChange={(v) => {
if (v) setGroupBy(v as typeof groupBy)
}}
variant="outline"
size="sm"
className="h-6"
>
<ToggleGroupItem value="none" className="h-6 px-2 text-[10px]">
All
</ToggleGroupItem>
<ToggleGroupItem value="pr-status" className="h-6 px-2 text-[10px]">
PR Status
</ToggleGroupItem>
<ToggleGroupItem value="repo" className="h-6 px-2 text-[10px]">
Repo
</ToggleGroupItem>
</ToggleGroup>
<Select value={sortBy} onValueChange={(v) => setSortBy(v as typeof sortBy)}>
<SelectTrigger
size="sm"
className="h-6 w-auto gap-1 border-none bg-transparent px-1.5 text-[10px] shadow-none focus-visible:ring-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectItem value="name">Name</SelectItem>
<SelectItem value="recent">Recent</SelectItem>
<SelectItem value="repo">Repo</SelectItem>
</SelectContent>
</Select>
</div>
)
})
export default GroupControls

View file

@ -0,0 +1,89 @@
import React, { useCallback } from 'react'
import { Search, X, Activity } from 'lucide-react'
import { useAppStore } from '@/store'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem
} from '@/components/ui/select'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
const SearchBar = React.memo(function SearchBar() {
const searchQuery = useAppStore((s) => s.searchQuery)
const setSearchQuery = useAppStore((s) => s.setSearchQuery)
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
const setShowActiveOnly = useAppStore((s) => s.setShowActiveOnly)
const filterRepoId = useAppStore((s) => s.filterRepoId)
const setFilterRepoId = useAppStore((s) => s.setFilterRepoId)
const repos = useAppStore((s) => s.repos)
const handleClear = useCallback(() => setSearchQuery(''), [setSearchQuery])
const handleToggleActive = useCallback(
() => setShowActiveOnly(!showActiveOnly),
[showActiveOnly, setShowActiveOnly]
)
return (
<div className="px-2 pb-1">
<div className="relative flex items-center">
<Search className="absolute left-2 size-3.5 text-muted-foreground pointer-events-none" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="h-7 pl-7 pr-20 text-xs border-none bg-muted/50 shadow-none focus-visible:ring-1 focus-visible:ring-ring/30"
/>
<div className="absolute right-1 flex items-center gap-0.5">
{searchQuery && (
<Button variant="ghost" size="icon-xs" onClick={handleClear} className="size-5">
<X className="size-3" />
</Button>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={handleToggleActive}
className={cn('size-5', showActiveOnly && 'bg-accent text-accent-foreground')}
>
<Activity className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{showActiveOnly ? 'Show all' : 'Active only'}
</TooltipContent>
</Tooltip>
{repos.length > 1 && (
<Select
value={filterRepoId ?? '__all__'}
onValueChange={(v) => setFilterRepoId(v === '__all__' ? null : v)}
>
<SelectTrigger
size="sm"
className="h-5 w-auto gap-1 border-none bg-transparent px-1 text-[10px] shadow-none focus-visible:ring-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectItem value="__all__">All repos</SelectItem>
{repos.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
)
})
export default SearchBar

View file

@ -0,0 +1,34 @@
import React from 'react'
import { Plus } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
const SidebarHeader = React.memo(function SidebarHeader() {
const openModal = useAppStore((s) => s.openModal)
return (
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground select-none">
Worktrees
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => openModal('create-worktree')}
aria-label="Add worktree"
>
<Plus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
New worktree
</TooltipContent>
</Tooltip>
</div>
)
})
export default SidebarHeader

View file

@ -0,0 +1,48 @@
import React from 'react'
import { FolderPlus, Settings } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
const SidebarToolbar = React.memo(function SidebarToolbar() {
const addRepo = useAppStore((s) => s.addRepo)
const setActiveView = useAppStore((s) => s.setActiveView)
return (
<div className="flex items-center justify-between px-2 py-1.5 border-t border-sidebar-border shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="xs"
onClick={() => addRepo()}
className="gap-1.5 text-muted-foreground"
>
<FolderPlus className="size-3.5" />
<span className="text-[11px]">Add Repo</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Open folder picker to add a repo
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setActiveView('settings')}
className="text-muted-foreground"
>
<Settings className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Settings
</TooltipContent>
</Tooltip>
</div>
)
})
export default SidebarToolbar

View file

@ -0,0 +1,70 @@
import React, { useState, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
const BRAILLE_FRAMES = [
'\u280B',
'\u2819',
'\u2839',
'\u2838',
'\u283C',
'\u2834',
'\u2826',
'\u2827',
'\u2807',
'\u280F'
]
const FRAME_INTERVAL = 80
type Status = 'active' | 'working' | 'inactive'
interface StatusIndicatorProps {
status: Status
className?: string
}
const StatusIndicator = React.memo(function StatusIndicator({
status,
className
}: StatusIndicatorProps) {
const [frame, setFrame] = useState(0)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (status === 'working') {
intervalRef.current = setInterval(() => {
setFrame((f) => (f + 1) % BRAILLE_FRAMES.length)
}, FRAME_INTERVAL)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}
setFrame(0)
return undefined
}, [status])
if (status === 'working') {
return (
<span
className={cn(
'text-[11px] leading-none text-foreground font-mono w-3 text-center shrink-0',
className
)}
>
{BRAILLE_FRAMES[frame]}
</span>
)
}
return (
<span
className={cn(
'block size-2 rounded-full shrink-0',
status === 'active' ? 'bg-emerald-500' : 'bg-neutral-500/40',
className
)}
/>
)
})
export default StatusIndicator
export type { Status }

View file

@ -0,0 +1,246 @@
import React, { useEffect, useMemo } from 'react'
import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import StatusIndicator from './StatusIndicator'
import WorktreeContextMenu from './WorktreeContextMenu'
import { cn } from '@/lib/utils'
import type {
Worktree,
Repo,
PRInfo,
IssueInfo,
PRState,
CheckStatus
} from '../../../../shared/types'
import type { Status } from './StatusIndicator'
function branchDisplayName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
const PRIMARY_BRANCHES = new Set(['main', 'master', 'develop', 'dev'])
function isPrimaryBranch(branch: string): boolean {
return PRIMARY_BRANCHES.has(branchDisplayName(branch))
}
function prStateLabel(state: PRState): string {
return state.charAt(0).toUpperCase() + state.slice(1)
}
function checksLabel(status: CheckStatus): string {
switch (status) {
case 'success':
return 'Passing'
case 'failure':
return 'Failing'
case 'pending':
return 'Pending'
default:
return ''
}
}
interface WorktreeCardProps {
worktree: Worktree
repo: Repo | undefined
isActive: boolean
}
const WorktreeCard = React.memo(function WorktreeCard({
worktree,
repo,
isActive
}: WorktreeCardProps) {
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const prCache = useAppStore((s) => s.prCache)
const issueCache = useAppStore((s) => s.issueCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchIssue = useAppStore((s) => s.fetchIssue)
const tabs = tabsByWorktree[worktree.id] ?? []
const hasTerminals = tabs.length > 0
const branch = branchDisplayName(worktree.branch)
// Derive status
const status: Status = useMemo(() => {
if (!hasTerminals) return 'inactive'
// Simple heuristic: if any tab has a pty, it's active
return tabs.some((t) => t.ptyId) ? 'active' : 'inactive'
}, [hasTerminals, tabs])
// Fetch PR data
const prCacheKey = repo ? `${repo.path}::${branch}` : ''
const pr: PRInfo | null | undefined = prCacheKey ? prCache[prCacheKey] : undefined
useEffect(() => {
if (repo && !worktree.isBare && pr === undefined) {
fetchPRForBranch(repo.path, branch)
}
}, [repo, worktree.isBare, pr, fetchPRForBranch, branch])
// Fetch issue data
const issue: IssueInfo | null | undefined = worktree.linkedIssue
? issueCache[worktree.linkedIssue]
: null
useEffect(() => {
if (repo && worktree.linkedIssue && issue === undefined) {
fetchIssue(repo.path, worktree.linkedIssue)
}
}, [repo, worktree.linkedIssue, issue, fetchIssue])
return (
<WorktreeContextMenu worktree={worktree}>
<div
className={cn(
'group relative flex items-start gap-2 px-2.5 py-1.5 rounded-md cursor-pointer transition-colors',
isActive ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => setActiveWorktree(worktree.id)}
>
{/* Status + unread indicator */}
<div className="flex items-center pt-1 gap-1">
<StatusIndicator status={status} />
{worktree.isUnread && (
<span className="block size-1.5 rounded-full bg-foreground/70 shrink-0" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 space-y-0.5">
{/* Line 1: Name */}
<div className="text-[12px] font-semibold text-foreground truncate leading-tight">
{worktree.displayName}
</div>
{/* Line 2: Repo badge + branch + primary badge */}
<div className="flex items-center gap-1 min-w-0">
{repo && (
<Badge
variant="secondary"
className="h-4 px-1.5 text-[9px] font-medium rounded-sm shrink-0"
style={{ backgroundColor: repo.badgeColor + '22', color: repo.badgeColor }}
>
{repo.displayName}
</Badge>
)}
<span className="text-[11px] text-muted-foreground truncate font-mono">{branch}</span>
{isPrimaryBranch(worktree.branch) && (
<Badge variant="outline" className="h-4 px-1 text-[9px] rounded-sm shrink-0">
main
</Badge>
)}
</div>
{/* Line 3: PR */}
{pr && (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<div className="flex items-center gap-1 min-w-0 cursor-default">
<span className="text-[10px] text-muted-foreground shrink-0">PR</span>
<span className="text-[10px] text-foreground/80 truncate">{pr.title}</span>
<Badge
variant="secondary"
className={cn(
'h-3.5 px-1 text-[8px] rounded-sm shrink-0',
pr.state === 'merged' && 'text-purple-400',
pr.state === 'open' && 'text-emerald-400',
pr.state === 'closed' && 'text-neutral-400',
pr.state === 'draft' && 'text-neutral-500'
)}
>
{prStateLabel(pr.state)}
</Badge>
</div>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-72 p-3 text-xs space-y-1.5">
<div className="font-semibold text-[13px]">
#{pr.number} {pr.title}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span>State: {prStateLabel(pr.state)}</span>
{pr.checksStatus !== 'neutral' && (
<span>Checks: {checksLabel(pr.checksStatus)}</span>
)}
</div>
<a
href={pr.url}
target="_blank"
rel="noreferrer"
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
View on GitHub
</a>
</HoverCardContent>
</HoverCard>
)}
{/* Line 4: Issue */}
{issue && (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<div className="flex items-center gap-1 min-w-0 cursor-default">
<span className="text-[10px] text-muted-foreground shrink-0">Issue</span>
<span className="text-[10px] text-foreground/80 truncate">{issue.title}</span>
<Badge
variant="secondary"
className={cn(
'h-3.5 px-1 text-[8px] rounded-sm shrink-0',
issue.state === 'open' ? 'text-emerald-400' : 'text-neutral-400'
)}
>
{issue.state === 'open' ? 'Open' : 'Closed'}
</Badge>
</div>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-72 p-3 text-xs space-y-1.5">
<div className="font-semibold text-[13px]">
#{issue.number} {issue.title}
</div>
<div className="text-muted-foreground">
State: {issue.state === 'open' ? 'Open' : 'Closed'}
</div>
{issue.labels.length > 0 && (
<div className="flex flex-wrap gap-1">
{issue.labels.map((l) => (
<Badge key={l} variant="outline" className="h-4 px-1.5 text-[9px]">
{l}
</Badge>
))}
</div>
)}
<a
href={issue.url}
target="_blank"
rel="noreferrer"
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
View on GitHub
</a>
</HoverCardContent>
</HoverCard>
)}
{/* Line 5: Comment */}
{worktree.comment && (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<div className="text-[10px] text-muted-foreground truncate cursor-default italic">
{worktree.comment}
</div>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-64 p-3 text-xs">
<p className="whitespace-pre-wrap">{worktree.comment}</p>
</HoverCardContent>
</HoverCard>
)}
</div>
</div>
</WorktreeContextMenu>
)
})
export default WorktreeCard

View file

@ -0,0 +1,116 @@
import React, { useCallback } from 'react'
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator
} from '@/components/ui/context-menu'
import {
FolderOpen,
Copy,
Eye,
EyeOff,
Link,
MessageSquare,
XCircle,
Archive,
Trash2
} from 'lucide-react'
import { useAppStore } from '@/store'
import type { Worktree } from '../../../../shared/types'
interface Props {
worktree: Worktree
children: React.ReactNode
}
const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, children }: Props) {
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const removeWorktree = useAppStore((s) => s.removeWorktree)
const openModal = useAppStore((s) => s.openModal)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const closeTab = useAppStore((s) => s.closeTab)
const handleOpenInFinder = useCallback(() => {
window.api.shell.openPath(worktree.path)
}, [worktree.path])
const handleCopyPath = useCallback(() => {
navigator.clipboard.writeText(worktree.path)
}, [worktree.path])
const handleToggleRead = useCallback(() => {
updateWorktreeMeta(worktree.id, { isUnread: !worktree.isUnread })
}, [worktree.id, worktree.isUnread, updateWorktreeMeta])
const handleLinkIssue = useCallback(() => {
openModal('link-issue', { worktreeId: worktree.id, currentIssue: worktree.linkedIssue })
}, [worktree.id, worktree.linkedIssue, openModal])
const handleComment = useCallback(() => {
openModal('edit-comment', { worktreeId: worktree.id, currentComment: worktree.comment })
}, [worktree.id, worktree.comment, openModal])
const handleCloseTerminals = useCallback(() => {
const tabs = tabsByWorktree[worktree.id] ?? []
for (const tab of tabs) {
if (tab.ptyId) {
window.api.pty.kill(tab.ptyId)
}
closeTab(tab.id)
}
}, [worktree.id, tabsByWorktree, closeTab])
const handleArchive = useCallback(() => {
updateWorktreeMeta(worktree.id, { isArchived: true })
}, [worktree.id, updateWorktreeMeta])
const handleDelete = useCallback(() => {
removeWorktree(worktree.id)
}, [worktree.id, removeWorktree])
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuItem onClick={handleOpenInFinder}>
<FolderOpen className="size-3.5" />
Open in Finder
</ContextMenuItem>
<ContextMenuItem onClick={handleCopyPath}>
<Copy className="size-3.5" />
Copy Path
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleToggleRead}>
{worktree.isUnread ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
{worktree.isUnread ? 'Mark Read' : 'Mark Unread'}
</ContextMenuItem>
<ContextMenuItem onClick={handleLinkIssue}>
<Link className="size-3.5" />
{worktree.linkedIssue ? 'Edit GH Issue' : 'Link GH Issue'}
</ContextMenuItem>
<ContextMenuItem onClick={handleComment}>
<MessageSquare className="size-3.5" />
{worktree.comment ? 'Edit Comment' : 'Add Comment'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleCloseTerminals}>
<XCircle className="size-3.5" />
Close Terminals
</ContextMenuItem>
<ContextMenuItem onClick={handleArchive}>
<Archive className="size-3.5" />
Archive
</ContextMenuItem>
<ContextMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-3.5" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})
export default WorktreeContextMenu

View file

@ -0,0 +1,173 @@
import React, { useMemo } from 'react'
import { useAppStore } from '@/store'
import WorktreeCard from './WorktreeCard'
import type { Worktree, Repo } from '../../../../shared/types'
function branchName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
const WorktreeList = React.memo(function WorktreeList() {
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const repos = useAppStore((s) => s.repos)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const searchQuery = useAppStore((s) => s.searchQuery)
const groupBy = useAppStore((s) => s.groupBy)
const sortBy = useAppStore((s) => s.sortBy)
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
const filterRepoId = useAppStore((s) => s.filterRepoId)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const prCache = useAppStore((s) => s.prCache)
const repoMap = useMemo(() => {
const m = new Map<string, Repo>()
for (const r of repos) m.set(r.id, r)
return m
}, [repos])
// Flatten, filter, sort
const worktrees = useMemo(() => {
let all: Worktree[] = Object.values(worktreesByRepo).flat()
// Filter archived
all = all.filter((w) => !w.isArchived)
// Filter by repo
if (filterRepoId) {
all = all.filter((w) => w.repoId === filterRepoId)
}
// Filter by search
if (searchQuery) {
const q = searchQuery.toLowerCase()
all = all.filter(
(w) =>
w.displayName.toLowerCase().includes(q) ||
branchName(w.branch).toLowerCase().includes(q) ||
(repoMap.get(w.repoId)?.displayName ?? '').toLowerCase().includes(q)
)
}
// Filter active only
if (showActiveOnly) {
all = all.filter((w) => {
const tabs = tabsByWorktree[w.id] ?? []
return tabs.some((t) => t.ptyId)
})
}
// Sort
all.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.displayName.localeCompare(b.displayName)
case 'recent':
return b.sortOrder - a.sortOrder
case 'repo': {
const ra = repoMap.get(a.repoId)?.displayName ?? ''
const rb = repoMap.get(b.repoId)?.displayName ?? ''
const cmp = ra.localeCompare(rb)
return cmp !== 0 ? cmp : a.displayName.localeCompare(b.displayName)
}
default:
return 0
}
})
return all
}, [worktreesByRepo, filterRepoId, searchQuery, showActiveOnly, sortBy, repoMap, tabsByWorktree])
// Group
const groups = useMemo(() => {
if (groupBy === 'none') {
return [{ label: null, items: worktrees }]
}
if (groupBy === 'repo') {
const map = new Map<string, Worktree[]>()
for (const w of worktrees) {
const label = repoMap.get(w.repoId)?.displayName ?? 'Unknown'
if (!map.has(label)) map.set(label, [])
map.get(label)!.push(w)
}
return Array.from(map.entries()).map(([label, items]) => ({ label, items }))
}
if (groupBy === 'pr-status') {
const buckets = new Map<string, Worktree[]>()
for (const w of worktrees) {
const repo = repoMap.get(w.repoId)
const branch = branchName(w.branch)
const cacheKey = repo ? `${repo.path}::${branch}` : ''
const pr = cacheKey ? prCache[cacheKey] : undefined
const label = pr ? pr.state.charAt(0).toUpperCase() + pr.state.slice(1) : 'No PR'
if (!buckets.has(label)) buckets.set(label, [])
buckets.get(label)!.push(w)
}
return Array.from(buckets.entries()).map(([label, items]) => ({ label, items }))
}
return [{ label: null, items: worktrees }]
}, [groupBy, worktrees, repoMap, prCache])
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set())
const toggleGroup = React.useCallback((label: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev)
if (next.has(label)) next.delete(label)
else next.add(label)
return next
})
}, [])
if (worktrees.length === 0) {
return (
<div className="px-4 py-6 text-center text-[11px] text-muted-foreground">
No worktrees found
</div>
)
}
return (
<div className="px-1 space-y-0.5">
{groups.map((group) => {
const key = group.label ?? '__all__'
const isCollapsed = group.label ? collapsedGroups.has(group.label) : false
return (
<div key={key}>
{group.label && (
<button
className="flex items-center gap-1 px-2 pt-2 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground w-full text-left hover:text-foreground transition-colors"
onClick={() => toggleGroup(group.label!)}
>
<span
className="inline-block transition-transform text-[8px]"
style={{ transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
>
&#9660;
</span>
{group.label}
<span className="ml-auto text-[9px] font-normal tabular-nums">
{group.items.length}
</span>
</button>
)}
{!isCollapsed &&
group.items.map((wt) => (
<WorktreeCard
key={wt.id}
worktree={wt}
repo={repoMap.get(wt.repoId)}
isActive={activeWorktreeId === wt.id}
/>
))}
</div>
)
})}
</div>
)
})
export default WorktreeList

View file

@ -0,0 +1,102 @@
import React, { useCallback, useRef, useEffect } from 'react'
import { useAppStore } from '@/store'
import { ScrollArea } from '@/components/ui/scroll-area'
import { TooltipProvider } from '@/components/ui/tooltip'
import SidebarHeader from './SidebarHeader'
import SearchBar from './SearchBar'
import GroupControls from './GroupControls'
import WorktreeList from './WorktreeList'
import SidebarToolbar from './SidebarToolbar'
import AddWorktreeDialog from './AddWorktreeDialog'
const MIN_WIDTH = 220
const MAX_WIDTH = 500
export default function Sidebar(): React.JSX.Element {
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
const sidebarWidth = useAppStore((s) => s.sidebarWidth)
const setSidebarWidth = useAppStore((s) => s.setSidebarWidth)
const repos = useAppStore((s) => s.repos)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
// Fetch worktrees when repos change
useEffect(() => {
if (repos.length > 0) {
fetchAllWorktrees()
}
}, [repos, fetchAllWorktrees])
// ─── Resize logic ────────────────────────────────────
const isResizing = useRef(false)
const startX = useRef(0)
const startWidth = useRef(0)
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isResizing.current) return
const delta = e.clientX - startX.current
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta))
setSidebarWidth(next)
},
[setSidebarWidth]
)
const handleMouseUp = useCallback(() => {
isResizing.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}, [])
useEffect(() => {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [handleMouseMove, handleMouseUp])
const onResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
isResizing.current = true
startX.current = e.clientX
startWidth.current = sidebarWidth
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
},
[sidebarWidth]
)
return (
<TooltipProvider delayDuration={400}>
<div
className="relative flex-shrink-0 bg-sidebar flex flex-col overflow-hidden transition-[width] duration-200"
style={{
width: sidebarOpen ? sidebarWidth : 0,
borderRight: sidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
}}
>
{/* Scrollable area: header + search + controls + list */}
<ScrollArea className="flex-1 min-h-0">
<SidebarHeader />
<SearchBar />
<GroupControls />
<WorktreeList />
</ScrollArea>
{/* Fixed bottom toolbar */}
<SidebarToolbar />
{/* Resize handle */}
<div
className="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
onMouseDown={onResizeStart}
/>
</div>
{/* Dialog (rendered outside sidebar to avoid clipping) */}
<AddWorktreeDialog />
</TooltipProvider>
)
}

View file

@ -0,0 +1,46 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90',
outline:
'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
link: 'text-primary underline-offset-4 [a&]:hover:underline'
}
},
defaultVariants: {
variant: 'default'
}
}
)
function Badge({
className,
variant = 'default',
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span'
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,62 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : 'button'
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,75 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }

View file

@ -0,0 +1,222 @@
import * as React from 'react'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { ContextMenu as ContextMenuPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
'z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
'z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:pl-8', className)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup
}

View file

@ -0,0 +1,144 @@
'use client'
import * as React from 'react'
import { XIcon } from 'lucide-react'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger
}

View file

@ -0,0 +1,226 @@
import * as React from 'react'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent
}

View file

@ -0,0 +1,38 @@
'use client'
import * as React from 'react'
import { HoverCard as HoverCardPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View file

@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,21 @@
'use client'
import * as React from 'react'
import { Label as LabelPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,54 @@
import * as React from 'react'
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View file

@ -0,0 +1,175 @@
'use client'
import * as React from 'react'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import { Select as SelectPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue
}

View file

@ -0,0 +1,26 @@
import * as React from 'react'
import { Separator as SeparatorPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -0,0 +1,81 @@
'use client'
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Tabs as TabsPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
function Tabs({
className,
orientation = 'horizontal',
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn('group/tabs flex gap-2 data-[orientation=horizontal]:flex-col', className)}
{...props}
/>
)
}
const tabsListVariants = cva(
'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent'
}
},
defaultVariants: {
variant: 'default'
}
}
)
function TabsList({
className,
variant = 'default',
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent',
'data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100',
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View file

@ -0,0 +1,82 @@
'use client'
import * as React from 'react'
import { type VariantProps } from 'class-variance-authority'
import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
import { toggleVariants } from '@/components/ui/toggle'
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: 'default',
variant: 'default',
spacing: 0
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ '--gap': spacing } as React.CSSProperties}
className={cn(
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs',
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size
}),
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View file

@ -0,0 +1,44 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Toggle as TogglePrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground'
},
size: {
default: 'h-9 min-w-9 px-2',
sm: 'h-8 min-w-8 px-1.5',
lg: 'h-10 min-w-10 px-2.5'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View file

@ -0,0 +1,51 @@
import * as React from 'react'
import { Tooltip as TooltipPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -0,0 +1,22 @@
import { useEffect } from 'react'
import { useAppStore } from '../store'
export function useIpcEvents(): void {
useEffect(() => {
const unsubs: (() => void)[] = []
unsubs.push(
window.api.repos.onChanged(() => {
useAppStore.getState().fetchRepos()
})
)
unsubs.push(
window.api.worktrees.onChanged((data: { repoId: string }) => {
useAppStore.getState().fetchWorktrees(data.repoId)
})
)
return () => unsubs.forEach((fn) => fn())
}, [])
}

View file

@ -1,47 +0,0 @@
import { create } from 'zustand'
export interface Worktree {
path: string
head: string
branch: string
isBare: boolean
}
export interface AppState {
sidebarOpen: boolean
toggleSidebar: () => void
worktrees: Worktree[]
activeWorktree: string | null
setActiveWorktree: (path: string) => void
fetchWorktrees: () => Promise<void>
showTerminal: boolean
setShowTerminal: (show: boolean) => void
terminalTitle: string
setTerminalTitle: (title: string) => void
}
export const useAppStore = create<AppState>((set) => ({
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
worktrees: [],
activeWorktree: null,
setActiveWorktree: (path) => set({ activeWorktree: path }),
fetchWorktrees: async () => {
try {
const worktrees = await window.api.worktrees.getCurrent()
set({ worktrees })
} catch (err) {
console.error('Failed to fetch worktrees:', err)
}
},
showTerminal: true,
setShowTerminal: (show) => set({ showTerminal: show }),
terminalTitle: '',
setTerminalTitle: (title) => set({ terminalTitle: title })
}))

View file

@ -0,0 +1,19 @@
import { create } from 'zustand'
import type { AppState } from './types'
import { createRepoSlice } from './slices/repos'
import { createWorktreeSlice } from './slices/worktrees'
import { createTerminalSlice } from './slices/terminals'
import { createUISlice } from './slices/ui'
import { createSettingsSlice } from './slices/settings'
import { createGitHubSlice } from './slices/github'
export const useAppStore = create<AppState>()((...a) => ({
...createRepoSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a)
}))
export type { AppState } from './types'

View file

@ -0,0 +1,37 @@
import { useAppStore } from './index'
// ─── Repos ──────────────────────────────────────────────────────────
export const useRepos = () => useAppStore((s) => s.repos)
export const useActiveRepoId = () => useAppStore((s) => s.activeRepoId)
export const useActiveRepo = () =>
useAppStore((s) => s.repos.find((r) => r.id === s.activeRepoId) ?? null)
// ─── Worktrees ──────────────────────────────────────────────────────
export const useActiveWorktreeId = () => useAppStore((s) => s.activeWorktreeId)
export const useWorktreesForRepo = (repoId: string | null) =>
useAppStore((s) => (repoId ? (s.worktreesByRepo[repoId] ?? []) : []))
export const useAllWorktrees = () => useAppStore((s) => Object.values(s.worktreesByRepo).flat())
// ─── Terminals ──────────────────────────────────────────────────────
export const useActiveTerminalTabs = () =>
useAppStore((s) => (s.activeWorktreeId ? (s.tabsByWorktree[s.activeWorktreeId] ?? []) : []))
export const useActiveTabId = () => useAppStore((s) => s.activeTabId)
// ─── Settings ───────────────────────────────────────────────────────
export const useSettings = () => useAppStore((s) => s.settings)
// ─── UI ─────────────────────────────────────────────────────────────
export const useSidebarOpen = () => useAppStore((s) => s.sidebarOpen)
export const useSidebarWidth = () => useAppStore((s) => s.sidebarWidth)
export const useActiveView = () => useAppStore((s) => s.activeView)
export const useActiveModal = () => useAppStore((s) => s.activeModal)
export const useModalData = () => useAppStore((s) => s.modalData)
export const useSearchQuery = () => useAppStore((s) => s.searchQuery)
export const useGroupBy = () => useAppStore((s) => s.groupBy)
export const useSortBy = () => useAppStore((s) => s.sortBy)
export const useShowActiveOnly = () => useAppStore((s) => s.showActiveOnly)
export const useFilterRepoId = () => useAppStore((s) => s.filterRepoId)
// ─── GitHub ─────────────────────────────────────────────────────────
export const usePRCache = () => useAppStore((s) => s.prCache)
export const useIssueCache = () => useAppStore((s) => s.issueCache)

View file

@ -0,0 +1,54 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { PRInfo, IssueInfo } from '../../../../shared/types'
export interface GitHubSlice {
prCache: Record<string, PRInfo | null>
issueCache: Record<number, IssueInfo | null>
fetchPRForBranch: (repoPath: string, branch: string) => Promise<PRInfo | null>
fetchIssue: (repoPath: string, number: number) => Promise<IssueInfo | null>
}
export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
prCache: {},
issueCache: {},
fetchPRForBranch: async (repoPath, branch) => {
const cacheKey = `${repoPath}::${branch}`
const cached = get().prCache[cacheKey]
if (cached !== undefined) return cached
try {
const pr = await window.api.gh.prForBranch({ repoPath, branch })
set((s) => ({
prCache: { ...s.prCache, [cacheKey]: pr }
}))
return pr
} catch (err) {
console.error('Failed to fetch PR:', err)
set((s) => ({
prCache: { ...s.prCache, [cacheKey]: null }
}))
return null
}
},
fetchIssue: async (repoPath, number) => {
const cached = get().issueCache[number]
if (cached !== undefined) return cached
try {
const issue = await window.api.gh.issue({ repoPath, number })
set((s) => ({
issueCache: { ...s.issueCache, [number]: issue }
}))
return issue
} catch (err) {
console.error('Failed to fetch issue:', err)
set((s) => ({
issueCache: { ...s.issueCache, [number]: null }
}))
return null
}
}
})

View file

@ -0,0 +1,68 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { Repo } from '../../../../shared/types'
export interface RepoSlice {
repos: Repo[]
activeRepoId: string | null
fetchRepos: () => Promise<void>
addRepo: () => Promise<Repo | null>
removeRepo: (repoId: string) => Promise<void>
updateRepo: (
repoId: string,
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor'>>
) => Promise<void>
setActiveRepo: (repoId: string | null) => void
}
export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set) => ({
repos: [],
activeRepoId: null,
fetchRepos: async () => {
try {
const repos = await window.api.repos.list()
set({ repos })
} catch (err) {
console.error('Failed to fetch repos:', err)
}
},
addRepo: async () => {
try {
const path = await window.api.repos.pickFolder()
if (!path) return null
const repo = await window.api.repos.add({ path })
set((s) => ({ repos: [...s.repos, repo] }))
return repo
} catch (err) {
console.error('Failed to add repo:', err)
return null
}
},
removeRepo: async (repoId) => {
try {
await window.api.repos.remove({ repoId })
set((s) => ({
repos: s.repos.filter((r) => r.id !== repoId),
activeRepoId: s.activeRepoId === repoId ? null : s.activeRepoId
}))
} catch (err) {
console.error('Failed to remove repo:', err)
}
},
updateRepo: async (repoId, updates) => {
try {
await window.api.repos.update({ repoId, updates })
set((s) => ({
repos: s.repos.map((r) => (r.id === repoId ? { ...r, ...updates } : r))
}))
} catch (err) {
console.error('Failed to update repo:', err)
}
},
setActiveRepo: (repoId) => set({ activeRepoId: repoId })
})

View file

@ -0,0 +1,33 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { GlobalSettings } from '../../../../shared/types'
export interface SettingsSlice {
settings: GlobalSettings | null
fetchSettings: () => Promise<void>
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
}
export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice> = (set) => ({
settings: null,
fetchSettings: async () => {
try {
const settings = await window.api.settings.get()
set({ settings })
} catch (err) {
console.error('Failed to fetch settings:', err)
}
},
updateSettings: async (updates) => {
try {
await window.api.settings.set(updates)
set((s) => ({
settings: s.settings ? { ...s.settings, ...updates } : null
}))
} catch (err) {
console.error('Failed to update settings:', err)
}
}
})

View file

@ -0,0 +1,94 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { TerminalTab } from '../../../../shared/types'
export interface TerminalSlice {
tabsByWorktree: Record<string, TerminalTab[]>
activeTabId: string | null
createTab: (worktreeId: string) => TerminalTab
closeTab: (tabId: string) => void
reorderTabs: (worktreeId: string, tabIds: string[]) => void
setActiveTab: (tabId: string) => void
updateTabTitle: (tabId: string, title: string) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
}
export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice> = (set, get) => ({
tabsByWorktree: {},
activeTabId: null,
createTab: (worktreeId) => {
const existing = get().tabsByWorktree[worktreeId] ?? []
const tab: TerminalTab = {
id: globalThis.crypto.randomUUID(),
ptyId: null,
worktreeId,
title: `Terminal ${existing.length + 1}`,
sortOrder: existing.length,
createdAt: Date.now()
}
set((s) => ({
tabsByWorktree: {
...s.tabsByWorktree,
[worktreeId]: [...(s.tabsByWorktree[worktreeId] ?? []), tab]
},
activeTabId: tab.id
}))
return tab
},
closeTab: (tabId) => {
set((s) => {
const next = { ...s.tabsByWorktree }
for (const wId of Object.keys(next)) {
const before = next[wId]
const after = before.filter((t) => t.id !== tabId)
if (after.length !== before.length) {
next[wId] = after
}
}
return {
tabsByWorktree: next,
activeTabId: s.activeTabId === tabId ? null : s.activeTabId
}
})
},
reorderTabs: (worktreeId, tabIds) => {
set((s) => {
const tabs = s.tabsByWorktree[worktreeId] ?? []
const tabMap = new Map(tabs.map((t) => [t.id, t]))
const reordered = tabIds
.map((id, i) => {
const tab = tabMap.get(id)
return tab ? { ...tab, sortOrder: i } : undefined
})
.filter((t): t is TerminalTab => t !== undefined)
return {
tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: reordered }
}
})
},
setActiveTab: (tabId) => set({ activeTabId: tabId }),
updateTabTitle: (tabId, title) => {
set((s) => {
const next = { ...s.tabsByWorktree }
for (const wId of Object.keys(next)) {
next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, title } : t))
}
return { tabsByWorktree: next }
})
},
updateTabPtyId: (tabId, ptyId) => {
set((s) => {
const next = { ...s.tabsByWorktree }
for (const wId of Object.keys(next)) {
next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, ptyId } : t))
}
return { tabsByWorktree: next }
})
}
})

View file

@ -0,0 +1,55 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
export interface UISlice {
sidebarOpen: boolean
sidebarWidth: number
toggleSidebar: () => void
setSidebarWidth: (width: number) => void
activeView: 'terminal' | 'settings'
setActiveView: (view: UISlice['activeView']) => void
activeModal: 'none' | 'create-worktree' | 'link-issue' | 'edit-comment'
modalData: Record<string, unknown>
openModal: (modal: UISlice['activeModal'], data?: Record<string, unknown>) => void
closeModal: () => void
searchQuery: string
setSearchQuery: (q: string) => void
groupBy: 'none' | 'repo' | 'pr-status'
setGroupBy: (g: UISlice['groupBy']) => void
sortBy: 'name' | 'recent' | 'repo'
setSortBy: (s: UISlice['sortBy']) => void
showActiveOnly: boolean
setShowActiveOnly: (v: boolean) => void
filterRepoId: string | null
setFilterRepoId: (id: string | null) => void
}
export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set) => ({
sidebarOpen: true,
sidebarWidth: 280,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarWidth: (width) => set({ sidebarWidth: width }),
activeView: 'terminal',
setActiveView: (view) => set({ activeView: view }),
activeModal: 'none',
modalData: {},
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
closeModal: () => set({ activeModal: 'none', modalData: {} }),
searchQuery: '',
setSearchQuery: (q) => set({ searchQuery: q }),
groupBy: 'none',
setGroupBy: (g) => set({ groupBy: g }),
sortBy: 'name',
setSortBy: (s) => set({ sortBy: s }),
showActiveOnly: false,
setShowActiveOnly: (v) => set({ showActiveOnly: v }),
filterRepoId: null,
setFilterRepoId: (id) => set({ filterRepoId: id })
})

View file

@ -0,0 +1,89 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { Worktree, WorktreeMeta } from '../../../../shared/types'
export interface WorktreeSlice {
worktreesByRepo: Record<string, Worktree[]>
activeWorktreeId: string | null
fetchWorktrees: (repoId: string) => Promise<void>
fetchAllWorktrees: () => Promise<void>
createWorktree: (repoId: string, name: string, baseBranch?: string) => Promise<Worktree | null>
removeWorktree: (worktreeId: string, force?: boolean) => Promise<void>
updateWorktreeMeta: (worktreeId: string, updates: Partial<WorktreeMeta>) => Promise<void>
setActiveWorktree: (worktreeId: string | null) => void
allWorktrees: () => Worktree[]
}
export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> = (set, get) => ({
worktreesByRepo: {},
activeWorktreeId: null,
fetchWorktrees: async (repoId) => {
try {
const worktrees = await window.api.worktrees.list({ repoId })
set((s) => ({
worktreesByRepo: { ...s.worktreesByRepo, [repoId]: worktrees }
}))
} catch (err) {
console.error(`Failed to fetch worktrees for repo ${repoId}:`, err)
}
},
fetchAllWorktrees: async () => {
const { repos } = get()
await Promise.all(repos.map((r) => get().fetchWorktrees(r.id)))
},
createWorktree: async (repoId, name, baseBranch) => {
try {
const worktree = await window.api.worktrees.create({ repoId, name, baseBranch })
set((s) => ({
worktreesByRepo: {
...s.worktreesByRepo,
[repoId]: [...(s.worktreesByRepo[repoId] ?? []), worktree]
}
}))
return worktree
} catch (err) {
console.error('Failed to create worktree:', err)
return null
}
},
removeWorktree: async (worktreeId, force) => {
try {
await window.api.worktrees.remove({ worktreeId, force })
set((s) => {
const next = { ...s.worktreesByRepo }
for (const repoId of Object.keys(next)) {
next[repoId] = next[repoId].filter((w) => w.id !== worktreeId)
}
return {
worktreesByRepo: next,
activeWorktreeId: s.activeWorktreeId === worktreeId ? null : s.activeWorktreeId
}
})
} catch (err) {
console.error('Failed to remove worktree:', err)
}
},
updateWorktreeMeta: async (worktreeId, updates) => {
try {
await window.api.worktrees.updateMeta({ worktreeId, updates })
set((s) => {
const next = { ...s.worktreesByRepo }
for (const repoId of Object.keys(next)) {
next[repoId] = next[repoId].map((w) => (w.id === worktreeId ? { ...w, ...updates } : w))
}
return { worktreesByRepo: next }
})
} catch (err) {
console.error('Failed to update worktree meta:', err)
}
},
setActiveWorktree: (worktreeId) => set({ activeWorktreeId: worktreeId }),
allWorktrees: () => Object.values(get().worktreesByRepo).flat()
})

View file

@ -0,0 +1,13 @@
import type { RepoSlice } from './slices/repos'
import type { WorktreeSlice } from './slices/worktrees'
import type { TerminalSlice } from './slices/terminals'
import type { UISlice } from './slices/ui'
import type { SettingsSlice } from './slices/settings'
import type { GitHubSlice } from './slices/github'
export type AppState = RepoSlice &
WorktreeSlice &
TerminalSlice &
UISlice &
SettingsSlice &
GitHubSlice

40
src/shared/constants.ts Normal file
View file

@ -0,0 +1,40 @@
import type { GlobalSettings, PersistedState } from './types'
export const SCHEMA_VERSION = 1
export const REPO_COLORS = [
'#737373', // neutral
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#14b8a6', // teal
'#8b5cf6', // purple
'#ec4899' // pink
] as const
export function getDefaultSettings(homedir: string): GlobalSettings {
return {
workspaceDir: `${homedir}/orca/workspaces`,
nestWorkspaces: true,
branchPrefix: 'git-username',
branchPrefixCustom: '',
theme: 'system',
terminalFontSize: 14,
terminalFontFamily: 'SF Mono'
}
}
export function getDefaultPersistedState(homedir: string): PersistedState {
return {
schemaVersion: SCHEMA_VERSION,
repos: [],
worktreeMeta: {},
settings: getDefaultSettings(homedir),
ui: {
lastActiveRepoId: null,
lastActiveWorktreeId: null,
sidebarWidth: 280
}
}
}

104
src/shared/types.ts Normal file
View file

@ -0,0 +1,104 @@
// ─── Repo ────────────────────────────────────────────────────────────
export interface Repo {
id: string
path: string
displayName: string
badgeColor: string
addedAt: number
}
// ─── Worktree (git-level) ────────────────────────────────────────────
export interface GitWorktreeInfo {
path: string
head: string
branch: string
isBare: boolean
}
// ─── Worktree (app-level, enriched) ──────────────────────────────────
export interface Worktree extends GitWorktreeInfo {
id: string // `${repoId}::${path}`
repoId: string
displayName: string
comment: string
linkedIssue: number | null
linkedPR: number | null
isArchived: boolean
isUnread: boolean
sortOrder: number
}
// ─── Worktree metadata (persisted user-authored fields only) ─────────
export interface WorktreeMeta {
displayName: string
comment: string
linkedIssue: number | null
linkedPR: number | null
isArchived: boolean
isUnread: boolean
sortOrder: number
}
// ─── Terminal Tab ────────────────────────────────────────────────────
export interface TerminalTab {
id: string
ptyId: string | null
worktreeId: string
title: string
sortOrder: number
createdAt: number
}
// ─── GitHub ──────────────────────────────────────────────────────────
export type PRState = 'open' | 'closed' | 'merged' | 'draft'
export type IssueState = 'open' | 'closed'
export type CheckStatus = 'pending' | 'success' | 'failure' | 'neutral'
export interface PRInfo {
number: number
title: string
state: PRState
url: string
checksStatus: CheckStatus
updatedAt: string
}
export interface IssueInfo {
number: number
title: string
state: IssueState
url: string
labels: string[]
}
// ─── Hooks (orca.yaml) ──────────────────────────────────────────────
export interface OrcaHooks {
scripts: {
setup?: string // Runs after worktree is created
archive?: string // Runs before worktree is archived
}
}
// ─── Settings ────────────────────────────────────────────────────────
export interface GlobalSettings {
workspaceDir: string
nestWorkspaces: boolean
branchPrefix: 'git-username' | 'custom' | 'none'
branchPrefixCustom: string
theme: 'system' | 'dark' | 'light'
terminalFontSize: number
terminalFontFamily: string
}
// ─── Persistence shape ──────────────────────────────────────────────
export interface PersistedState {
schemaVersion: number
repos: Repo[]
worktreeMeta: Record<string, WorktreeMeta>
settings: GlobalSettings
ui: {
lastActiveRepoId: string | null
lastActiveWorktreeId: string | null
sidebarWidth: number
}
}

View file

@ -1,6 +1,6 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/shared/**/*"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]

View file

@ -4,7 +4,8 @@
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
"src/preload/*.d.ts",
"src/shared/**/*"
],
"compilerOptions": {
"composite": true,