mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
Major features
This commit is contained in:
parent
06eac79279
commit
7f29249d48
66 changed files with 5230 additions and 628 deletions
|
|
@ -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()]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
98
src/main/git/repo.ts
Normal 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
87
src/main/git/worktree.ts
Normal 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
126
src/main/github/client.ts
Normal 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
126
src/main/hooks.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
16
src/main/ipc/github.ts
Normal 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
73
src/main/ipc/pty.ts
Normal 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
65
src/main/ipc/repos.ts
Normal 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
13
src/main/ipc/settings.ts
Normal 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
11
src/main/ipc/shell.ts
Normal 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
175
src/main/ipc/worktrees.ts
Normal 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
160
src/main/persistence.ts
Normal 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
|
||||
}
|
||||
}
|
||||
61
src/preload/index.d.ts
vendored
61
src/preload/index.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
373
src/renderer/src/components/Settings.tsx
Normal file
373
src/renderer/src/components/Settings.tsx
Normal 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
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
144
src/renderer/src/components/TabBar.tsx
Normal file
144
src/renderer/src/components/TabBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
260
src/renderer/src/components/TerminalPane.tsx
Normal file
260
src/renderer/src/components/TerminalPane.tsx
Normal 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' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
171
src/renderer/src/components/sidebar/AddWorktreeDialog.tsx
Normal file
171
src/renderer/src/components/sidebar/AddWorktreeDialog.tsx
Normal 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
|
||||
57
src/renderer/src/components/sidebar/GroupControls.tsx
Normal file
57
src/renderer/src/components/sidebar/GroupControls.tsx
Normal 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
|
||||
89
src/renderer/src/components/sidebar/SearchBar.tsx
Normal file
89
src/renderer/src/components/sidebar/SearchBar.tsx
Normal 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
|
||||
34
src/renderer/src/components/sidebar/SidebarHeader.tsx
Normal file
34
src/renderer/src/components/sidebar/SidebarHeader.tsx
Normal 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
|
||||
48
src/renderer/src/components/sidebar/SidebarToolbar.tsx
Normal file
48
src/renderer/src/components/sidebar/SidebarToolbar.tsx
Normal 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
|
||||
70
src/renderer/src/components/sidebar/StatusIndicator.tsx
Normal file
70
src/renderer/src/components/sidebar/StatusIndicator.tsx
Normal 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 }
|
||||
246
src/renderer/src/components/sidebar/WorktreeCard.tsx
Normal file
246
src/renderer/src/components/sidebar/WorktreeCard.tsx
Normal 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
|
||||
116
src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
Normal file
116
src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
Normal 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
|
||||
173
src/renderer/src/components/sidebar/WorktreeList.tsx
Normal file
173
src/renderer/src/components/sidebar/WorktreeList.tsx
Normal 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)' }}
|
||||
>
|
||||
▼
|
||||
</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
|
||||
102
src/renderer/src/components/sidebar/index.tsx
Normal file
102
src/renderer/src/components/sidebar/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
src/renderer/src/components/ui/badge.tsx
Normal file
46
src/renderer/src/components/ui/badge.tsx
Normal 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 }
|
||||
62
src/renderer/src/components/ui/button.tsx
Normal file
62
src/renderer/src/components/ui/button.tsx
Normal 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 }
|
||||
75
src/renderer/src/components/ui/card.tsx
Normal file
75
src/renderer/src/components/ui/card.tsx
Normal 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 }
|
||||
222
src/renderer/src/components/ui/context-menu.tsx
Normal file
222
src/renderer/src/components/ui/context-menu.tsx
Normal 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
|
||||
}
|
||||
144
src/renderer/src/components/ui/dialog.tsx
Normal file
144
src/renderer/src/components/ui/dialog.tsx
Normal 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
|
||||
}
|
||||
226
src/renderer/src/components/ui/dropdown-menu.tsx
Normal file
226
src/renderer/src/components/ui/dropdown-menu.tsx
Normal 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
|
||||
}
|
||||
38
src/renderer/src/components/ui/hover-card.tsx
Normal file
38
src/renderer/src/components/ui/hover-card.tsx
Normal 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 }
|
||||
21
src/renderer/src/components/ui/input.tsx
Normal file
21
src/renderer/src/components/ui/input.tsx
Normal 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 }
|
||||
21
src/renderer/src/components/ui/label.tsx
Normal file
21
src/renderer/src/components/ui/label.tsx
Normal 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 }
|
||||
54
src/renderer/src/components/ui/scroll-area.tsx
Normal file
54
src/renderer/src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
175
src/renderer/src/components/ui/select.tsx
Normal file
175
src/renderer/src/components/ui/select.tsx
Normal 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
|
||||
}
|
||||
26
src/renderer/src/components/ui/separator.tsx
Normal file
26
src/renderer/src/components/ui/separator.tsx
Normal 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 }
|
||||
81
src/renderer/src/components/ui/tabs.tsx
Normal file
81
src/renderer/src/components/ui/tabs.tsx
Normal 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 }
|
||||
82
src/renderer/src/components/ui/toggle-group.tsx
Normal file
82
src/renderer/src/components/ui/toggle-group.tsx
Normal 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 }
|
||||
44
src/renderer/src/components/ui/toggle.tsx
Normal file
44
src/renderer/src/components/ui/toggle.tsx
Normal 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 }
|
||||
51
src/renderer/src/components/ui/tooltip.tsx
Normal file
51
src/renderer/src/components/ui/tooltip.tsx
Normal 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 }
|
||||
22
src/renderer/src/hooks/useIpcEvents.ts
Normal file
22
src/renderer/src/hooks/useIpcEvents.ts
Normal 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())
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}))
|
||||
19
src/renderer/src/store/index.ts
Normal file
19
src/renderer/src/store/index.ts
Normal 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'
|
||||
37
src/renderer/src/store/selectors.ts
Normal file
37
src/renderer/src/store/selectors.ts
Normal 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)
|
||||
54
src/renderer/src/store/slices/github.ts
Normal file
54
src/renderer/src/store/slices/github.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
68
src/renderer/src/store/slices/repos.ts
Normal file
68
src/renderer/src/store/slices/repos.ts
Normal 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 })
|
||||
})
|
||||
33
src/renderer/src/store/slices/settings.ts
Normal file
33
src/renderer/src/store/slices/settings.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
94
src/renderer/src/store/slices/terminals.ts
Normal file
94
src/renderer/src/store/slices/terminals.ts
Normal 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 }
|
||||
})
|
||||
}
|
||||
})
|
||||
55
src/renderer/src/store/slices/ui.ts
Normal file
55
src/renderer/src/store/slices/ui.ts
Normal 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 })
|
||||
})
|
||||
89
src/renderer/src/store/slices/worktrees.ts
Normal file
89
src/renderer/src/store/slices/worktrees.ts
Normal 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()
|
||||
})
|
||||
13
src/renderer/src/store/types.ts
Normal file
13
src/renderer/src/store/types.ts
Normal 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
40
src/shared/constants.ts
Normal 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
104
src/shared/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue