mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
some fixes
This commit is contained in:
parent
c89ba34ec8
commit
93996ddb09
20 changed files with 536 additions and 167 deletions
|
|
@ -27,6 +27,7 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"electron-updater": "^6.3.9",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ importers:
|
|||
'@electron-toolkit/utils':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(electron@41.0.2)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.23
|
||||
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
|
@ -1974,6 +1977,15 @@ packages:
|
|||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/react-virtual@3.13.23':
|
||||
resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/virtual-core@3.13.23':
|
||||
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
|
||||
|
||||
|
|
@ -6099,6 +6111,14 @@ snapshots:
|
|||
tailwindcss: 4.2.1
|
||||
vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)
|
||||
|
||||
'@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.23
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@tanstack/virtual-core@3.13.23': {}
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
dependencies:
|
||||
fast-glob: 3.3.3
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { execFileSync } from 'child_process'
|
||||
import { execFile, execFileSync } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import type { GitWorktreeInfo } from '../../shared/types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
/**
|
||||
* Parse the porcelain output of `git worktree list --porcelain`.
|
||||
*/
|
||||
|
|
@ -40,14 +43,13 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] {
|
|||
/**
|
||||
* List all worktrees for a git repo at the given path.
|
||||
*/
|
||||
export function listWorktrees(repoPath: string): GitWorktreeInfo[] {
|
||||
export async function listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]> {
|
||||
try {
|
||||
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
||||
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
return parseWorktreeList(output)
|
||||
return parseWorktreeList(stdout)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,51 @@
|
|||
import { execFileSync } from 'child_process'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import type { PRInfo, IssueInfo, CheckStatus } from '../../shared/types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// Concurrency limiter - max 4 parallel gh processes
|
||||
const MAX_CONCURRENT = 4
|
||||
let running = 0
|
||||
const queue: Array<() => void> = []
|
||||
|
||||
function acquire(): Promise<void> {
|
||||
if (running < MAX_CONCURRENT) {
|
||||
running++
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) =>
|
||||
queue.push(() => {
|
||||
running++
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
running--
|
||||
const next = queue.shift()
|
||||
if (next) next()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
export async function getPRForBranch(repoPath: string, branch: string): Promise<PRInfo | null> {
|
||||
await acquire()
|
||||
try {
|
||||
// Strip refs/heads/ prefix if present
|
||||
const branchName = branch.replace(/^refs\/heads\//, '')
|
||||
const raw = execFileSync(
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
['pr', 'view', branchName, '--json', 'number,title,state,url,statusCheckRollup,updatedAt'],
|
||||
{
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
encoding: 'utf-8'
|
||||
}
|
||||
)
|
||||
const data = JSON.parse(raw)
|
||||
const data = JSON.parse(stdout)
|
||||
return {
|
||||
number: data.number,
|
||||
title: data.title,
|
||||
|
|
@ -29,24 +56,26 @@ export function getPRForBranch(repoPath: string, branch: string): PRInfo | null
|
|||
}
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single issue by number.
|
||||
*/
|
||||
export function getIssue(repoPath: string, issueNumber: number): IssueInfo | null {
|
||||
export async function getIssue(repoPath: string, issueNumber: number): Promise<IssueInfo | null> {
|
||||
await acquire()
|
||||
try {
|
||||
const raw = execFileSync(
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels'],
|
||||
{
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
encoding: 'utf-8'
|
||||
}
|
||||
)
|
||||
const data = JSON.parse(raw)
|
||||
const data = JSON.parse(stdout)
|
||||
return {
|
||||
number: data.number,
|
||||
title: data.title,
|
||||
|
|
@ -56,24 +85,26 @@ export function getIssue(repoPath: string, issueNumber: number): IssueInfo | nul
|
|||
}
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List issues for a repo.
|
||||
*/
|
||||
export function listIssues(repoPath: string, limit = 20): IssueInfo[] {
|
||||
export async function listIssues(repoPath: string, limit = 20): Promise<IssueInfo[]> {
|
||||
await acquire()
|
||||
try {
|
||||
const raw = execFileSync(
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
['issue', 'list', '--json', 'number,title,state,url,labels', '--limit', String(limit)],
|
||||
{
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
encoding: 'utf-8'
|
||||
}
|
||||
)
|
||||
const data = JSON.parse(raw) as Array<{
|
||||
const data = JSON.parse(stdout) as Array<{
|
||||
number: number
|
||||
title: string
|
||||
state: string
|
||||
|
|
@ -89,6 +120,8 @@ export function listIssues(repoPath: string, limit = 20): IssueInfo[] {
|
|||
}))
|
||||
} catch {
|
||||
return []
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import type { GlobalSettings, PersistedState } from '../../shared/types'
|
||||
|
||||
export function registerSettingsHandlers(store: Store): void {
|
||||
ipcMain.handle('settings:get', () => {
|
||||
|
|
@ -10,4 +10,12 @@ export function registerSettingsHandlers(store: Store): void {
|
|||
ipcMain.handle('settings:set', (_event, args: Partial<GlobalSettings>) => {
|
||||
return store.updateSettings(args)
|
||||
})
|
||||
|
||||
ipcMain.handle('cache:getGitHub', () => {
|
||||
return store.getGitHubCache()
|
||||
})
|
||||
|
||||
ipcMain.handle('cache:setGitHub', (_event, args: { cache: PersistedState['githubCache'] }) => {
|
||||
store.setGitHubCache(args.cache)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import { getGitUsername, getDefaultBranch } from '../git/repo'
|
|||
import { loadHooks, runHook, hasHooksFile } from '../hooks'
|
||||
|
||||
export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store): void {
|
||||
ipcMain.handle('worktrees:listAll', () => {
|
||||
ipcMain.handle('worktrees:listAll', async () => {
|
||||
const repos = store.getRepos()
|
||||
const allWorktrees: Worktree[] = []
|
||||
|
||||
for (const repo of repos) {
|
||||
const gitWorktrees = listWorktrees(repo.path)
|
||||
const gitWorktrees = await listWorktrees(repo.path)
|
||||
for (const gw of gitWorktrees) {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
|
|
@ -23,11 +23,11 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
|
|||
return allWorktrees
|
||||
})
|
||||
|
||||
ipcMain.handle('worktrees:list', (_event, args: { repoId: string }) => {
|
||||
ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) return []
|
||||
|
||||
const gitWorktrees = listWorktrees(repo.path)
|
||||
const gitWorktrees = await listWorktrees(repo.path)
|
||||
return gitWorktrees.map((gw) => {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
|
|
@ -37,7 +37,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
|
|||
|
||||
ipcMain.handle(
|
||||
'worktrees:create',
|
||||
(_event, args: { repoId: string; name: string; baseBranch?: string }) => {
|
||||
async (_event, args: { repoId: string; name: string; baseBranch?: string }) => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) throw new Error(`Repo not found: ${args.repoId}`)
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
|
|||
addWorktree(repo.path, worktreePath, branchName, baseBranch)
|
||||
|
||||
// Re-list to get the freshly created worktree info
|
||||
const gitWorktrees = listWorktrees(repo.path)
|
||||
const gitWorktrees = await listWorktrees(repo.path)
|
||||
const created = gitWorktrees.find((gw) => gw.path === worktreePath)
|
||||
if (!created) throw new Error('Worktree created but not found in listing')
|
||||
|
||||
|
|
|
|||
|
|
@ -132,6 +132,17 @@ export class Store {
|
|||
this.scheduleSave()
|
||||
}
|
||||
|
||||
// ── GitHub Cache ──────────────────────────────────────────────────
|
||||
|
||||
getGitHubCache(): PersistedState['githubCache'] {
|
||||
return this.state.githubCache
|
||||
}
|
||||
|
||||
setGitHubCache(cache: PersistedState['githubCache']): void {
|
||||
this.state.githubCache = cache
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
// ── Flush (for shutdown) ───────────────────────────────────────────
|
||||
|
||||
flush(): void {
|
||||
|
|
|
|||
14
src/preload/index.d.ts
vendored
14
src/preload/index.d.ts
vendored
|
|
@ -59,6 +59,19 @@ interface HooksApi {
|
|||
check: (args: { repoId: string }) => Promise<{ hasHooks: boolean; hooks: OrcaHooks | null }>
|
||||
}
|
||||
|
||||
interface CacheApi {
|
||||
getGitHub: () => Promise<{
|
||||
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
|
||||
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
|
||||
}>
|
||||
setGitHub: (args: {
|
||||
cache: {
|
||||
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
|
||||
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
|
||||
}
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
interface Api {
|
||||
repos: ReposApi
|
||||
worktrees: WorktreesApi
|
||||
|
|
@ -67,6 +80,7 @@ interface Api {
|
|||
settings: SettingsApi
|
||||
shell: ShellApi
|
||||
hooks: HooksApi
|
||||
cache: CacheApi
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,11 @@ const api = {
|
|||
hooks: {
|
||||
check: (args: { repoId: string }): Promise<{ hasHooks: boolean; hooks: unknown }> =>
|
||||
ipcRenderer.invoke('hooks:check', args)
|
||||
},
|
||||
|
||||
cache: {
|
||||
getGitHub: () => ipcRenderer.invoke('cache:getGitHub'),
|
||||
setGitHub: (args: { cache: unknown }) => ipcRenderer.invoke('cache:setGitHub', args)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,17 +12,19 @@ function App(): React.JSX.Element {
|
|||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const fetchRepos = useAppStore((s) => s.fetchRepos)
|
||||
const fetchSettings = useAppStore((s) => s.fetchSettings)
|
||||
const initGitHubCache = useAppStore((s) => s.initGitHubCache)
|
||||
|
||||
// Subscribe to IPC push events
|
||||
useIpcEvents()
|
||||
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
|
||||
// Fetch initial data
|
||||
// Fetch initial data + hydrate GitHub cache from disk
|
||||
useEffect(() => {
|
||||
fetchRepos()
|
||||
fetchSettings()
|
||||
}, [fetchRepos, fetchSettings])
|
||||
initGitHubCache()
|
||||
}, [fetchRepos, fetchSettings, initGitHubCache])
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -145,6 +145,37 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* ── Sleek scrollbar (hover-only, thin) ─────────────── */
|
||||
|
||||
.scrollbar-sleek {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.scrollbar-sleek::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-sleek::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-sleek::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-sleek-parent:hover .scrollbar-sleek::-webkit-scrollbar-thumb,
|
||||
.scrollbar-sleek:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--muted-foreground, #737373);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.scrollbar-sleek-parent:hover .scrollbar-sleek,
|
||||
.scrollbar-sleek:hover {
|
||||
scrollbar-color: var(--muted-foreground, #737373) transparent;
|
||||
}
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────── */
|
||||
|
||||
#root {
|
||||
|
|
|
|||
|
|
@ -250,6 +250,52 @@ export default function TerminalPane({
|
|||
wasActiveRef.current = isActive
|
||||
}, [isActive])
|
||||
|
||||
// Terminal pane shortcuts handled at window capture phase so they remain
|
||||
// reliable even when focus is inside the canvas/IME internals.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (!isActive || e.repeat) return
|
||||
if (!e.metaKey || e.altKey || e.ctrlKey) return
|
||||
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
|
||||
// Cmd+K clears active pane screen + scrollback.
|
||||
if (!e.shiftKey && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const activePane = restty.activePane?.()
|
||||
if (activePane) {
|
||||
activePane.clearScreen()
|
||||
return
|
||||
}
|
||||
const pane = restty.getActivePane() ?? restty.getPanes()[0]
|
||||
pane?.app.clearScreen()
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd+[ / Cmd+] cycles active split pane focus.
|
||||
if (!e.shiftKey && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
|
||||
const panes = restty.getPanes()
|
||||
if (panes.length < 2) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const activeId = restty.getActivePane()?.id ?? panes[0].id
|
||||
const currentIdx = panes.findIndex((p) => p.id === activeId)
|
||||
if (currentIdx === -1) return
|
||||
|
||||
const dir = e.code === 'BracketRight' ? 1 : -1
|
||||
const nextPane = panes[(currentIdx + dir + panes.length) % panes.length]
|
||||
restty.setActivePane(nextPane.id, { focus: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [isActive])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useSyncExternalStore } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const BRAILLE_FRAMES = [
|
||||
|
|
@ -15,7 +15,47 @@ const BRAILLE_FRAMES = [
|
|||
]
|
||||
const FRAME_INTERVAL = 80
|
||||
|
||||
type Status = 'active' | 'working' | 'inactive'
|
||||
// ── Shared global spinner ────────────────────────────────────────
|
||||
// A single setInterval drives ALL spinner instances. Components
|
||||
// subscribe via useSyncExternalStore — zero per-instance timers.
|
||||
let _frame = 0
|
||||
let _subscribers = 0
|
||||
let _interval: ReturnType<typeof setInterval> | null = null
|
||||
const _listeners = new Set<() => void>()
|
||||
|
||||
function startSharedTimer(): void {
|
||||
if (_interval !== null) return
|
||||
_interval = setInterval(() => {
|
||||
_frame = (_frame + 1) % BRAILLE_FRAMES.length
|
||||
for (const cb of _listeners) cb()
|
||||
}, FRAME_INTERVAL)
|
||||
}
|
||||
|
||||
function stopSharedTimer(): void {
|
||||
if (_interval === null) return
|
||||
clearInterval(_interval)
|
||||
_interval = null
|
||||
_frame = 0
|
||||
}
|
||||
|
||||
function subscribeFrame(cb: () => void): () => void {
|
||||
_listeners.add(cb)
|
||||
_subscribers++
|
||||
if (_subscribers === 1) startSharedTimer()
|
||||
return () => {
|
||||
_listeners.delete(cb)
|
||||
_subscribers--
|
||||
if (_subscribers === 0) stopSharedTimer()
|
||||
}
|
||||
}
|
||||
|
||||
function getFrame(): number {
|
||||
return _frame
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Status = 'active' | 'working' | 'permission' | 'inactive'
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
status: Status
|
||||
|
|
@ -26,27 +66,18 @@ 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])
|
||||
// Only subscribes to the shared timer when status === 'working'.
|
||||
// When not working, the subscribe is a no-op (returns identity unsub).
|
||||
const frame = useSyncExternalStore(
|
||||
status === 'working' ? subscribeFrame : noopSubscribe,
|
||||
getFrame
|
||||
)
|
||||
|
||||
if (status === 'working') {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] leading-none text-foreground font-mono w-3 text-center shrink-0',
|
||||
'inline-flex h-3 w-3 items-center justify-center shrink-0 text-[11px] leading-none text-foreground font-mono',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -56,15 +87,24 @@ const StatusIndicator = React.memo(function StatusIndicator({
|
|||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'block size-2 rounded-full shrink-0',
|
||||
status === 'active' ? 'bg-emerald-500' : 'bg-neutral-500/40',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
<span className={cn('inline-flex h-3 w-3 items-center justify-center shrink-0', className)}>
|
||||
<span
|
||||
className={cn(
|
||||
'block size-2 rounded-full',
|
||||
status === 'active'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'permission'
|
||||
? 'bg-red-500'
|
||||
: 'bg-neutral-500/40'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const noopUnsubscribe = (): void => {}
|
||||
const noopSubscribe = (): (() => void) => noopUnsubscribe
|
||||
|
||||
export default StatusIndicator
|
||||
export type { Status }
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useCallback } 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 { detectAgentStatusFromTitle } from '@/lib/agent-status'
|
||||
import type {
|
||||
Worktree,
|
||||
Repo,
|
||||
PRInfo,
|
||||
IssueInfo,
|
||||
PRState,
|
||||
CheckStatus
|
||||
CheckStatus,
|
||||
TerminalTab
|
||||
} from '../../../../shared/types'
|
||||
import type { Status } from './StatusIndicator'
|
||||
|
||||
|
|
@ -42,6 +44,9 @@ function checksLabel(status: CheckStatus): string {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Stable empty array for tabs fallback ─────────────────────────
|
||||
const EMPTY_TABS: TerminalTab[] = []
|
||||
|
||||
interface WorktreeCardProps {
|
||||
worktree: Worktree
|
||||
repo: Repo | undefined
|
||||
|
|
@ -54,29 +59,39 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
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
|
||||
// ── GRANULAR selectors: only subscribe to THIS worktree's data ──
|
||||
const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS)
|
||||
|
||||
const branch = branchDisplayName(worktree.branch)
|
||||
const prCacheKey = repo ? `${repo.path}::${branch}` : ''
|
||||
const issueCacheKey = repo && worktree.linkedIssue ? `${repo.path}::${worktree.linkedIssue}` : ''
|
||||
|
||||
// Subscribe to ONLY the specific cache entry, not entire prCache/issueCache
|
||||
const prEntry = useAppStore((s) => (prCacheKey ? s.prCache[prCacheKey] : undefined))
|
||||
const issueEntry = useAppStore((s) => (issueCacheKey ? s.issueCache[issueCacheKey] : undefined))
|
||||
|
||||
const pr: PRInfo | null | undefined = prEntry !== undefined ? prEntry.data : undefined
|
||||
const issue: IssueInfo | null | undefined = worktree.linkedIssue
|
||||
? issueEntry !== undefined
|
||||
? issueEntry.data
|
||||
: undefined
|
||||
: null
|
||||
|
||||
const hasTerminals = tabs.length > 0
|
||||
|
||||
// Derive status
|
||||
const status: Status = useMemo(() => {
|
||||
if (!hasTerminals) return 'inactive'
|
||||
// Simple heuristic: if any tab has a pty, it's active
|
||||
if (tabs.some((t) => detectAgentStatusFromTitle(t.title) === 'permission')) return 'permission'
|
||||
if (tabs.some((t) => detectAgentStatusFromTitle(t.title) === 'working')) return 'working'
|
||||
return tabs.some((t) => t.ptyId) ? 'active' : 'inactive'
|
||||
}, [hasTerminals, tabs])
|
||||
|
||||
// Fetch PR data
|
||||
const prCacheKey = repo ? `${repo.path}::${branch}` : ''
|
||||
const prEntry = prCacheKey ? prCache[prCacheKey] : undefined
|
||||
const pr: PRInfo | null | undefined = prEntry !== undefined ? prEntry.data : undefined
|
||||
// Fetch PR data (debounced via ref guard)
|
||||
const prFetchedRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
repo &&
|
||||
|
|
@ -90,30 +105,33 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
}
|
||||
}, [repo, worktree.isBare, pr, fetchPRForBranch, branch, prCacheKey])
|
||||
|
||||
// Fetch issue data
|
||||
const issueCacheKey = repo && worktree.linkedIssue ? `${repo.path}::${worktree.linkedIssue}` : ''
|
||||
const issueEntry = issueCacheKey ? issueCache[issueCacheKey] : undefined
|
||||
const issue: IssueInfo | null | undefined = worktree.linkedIssue
|
||||
? issueEntry !== undefined
|
||||
? issueEntry.data
|
||||
: undefined
|
||||
: null
|
||||
// Fetch issue data (debounced via ref guard)
|
||||
const issueFetchedRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const issueKey = worktree.linkedIssue ?? null
|
||||
if (
|
||||
repo &&
|
||||
issueKey &&
|
||||
worktree.linkedIssue &&
|
||||
issue === undefined &&
|
||||
issueCacheKey &&
|
||||
issueCacheKey !== issueFetchedRef.current
|
||||
) {
|
||||
issueFetchedRef.current = issueCacheKey
|
||||
fetchIssue(repo.path, issueKey)
|
||||
fetchIssue(repo.path, worktree.linkedIssue)
|
||||
}
|
||||
}, [repo, worktree.linkedIssue, issue, fetchIssue, issueCacheKey])
|
||||
|
||||
// Stable click handler
|
||||
const handleClick = useCallback(
|
||||
() => setActiveWorktree(worktree.id),
|
||||
[worktree.id, setActiveWorktree]
|
||||
)
|
||||
|
||||
// Memoize badge style to avoid new object each render
|
||||
const badgeStyle = useMemo(
|
||||
() => (repo ? { backgroundColor: repo.badgeColor + '22', color: repo.badgeColor } : undefined),
|
||||
[repo?.badgeColor]
|
||||
)
|
||||
|
||||
return (
|
||||
<WorktreeContextMenu worktree={worktree}>
|
||||
<div
|
||||
|
|
@ -121,7 +139,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
'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)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Status + unread indicator */}
|
||||
<div className="flex items-center pt-1 gap-1">
|
||||
|
|
@ -144,7 +162,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
<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 }}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{repo.displayName}
|
||||
</Badge>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useCallback, useRef, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useAppStore } from '@/store'
|
||||
import WorktreeCard from './WorktreeCard'
|
||||
import type { Worktree, Repo } from '../../../../shared/types'
|
||||
|
|
@ -7,7 +8,13 @@ function branchName(branch: string): string {
|
|||
return branch.replace(/^refs\/heads\//, '')
|
||||
}
|
||||
|
||||
// ── Row types for the virtualizer ───────────────────────────────
|
||||
type GroupHeaderRow = { type: 'header'; label: string; count: number }
|
||||
type WorktreeRow = { type: 'item'; worktree: Worktree; repo: Repo | undefined }
|
||||
type Row = GroupHeaderRow | WorktreeRow
|
||||
|
||||
const WorktreeList = React.memo(function WorktreeList() {
|
||||
// ── Granular selectors (each is a primitive or shallow-stable ref) ──
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
|
|
@ -16,8 +23,14 @@ const WorktreeList = React.memo(function WorktreeList() {
|
|||
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)
|
||||
|
||||
// Only read tabsByWorktree when showActiveOnly is on (avoid subscription otherwise)
|
||||
const tabsByWorktree = useAppStore((s) => (showActiveOnly ? s.tabsByWorktree : null))
|
||||
|
||||
// PR cache only when grouping by pr-status
|
||||
const prCache = useAppStore((s) => (groupBy === 'pr-status' ? s.prCache : null))
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const repoMap = useMemo(() => {
|
||||
const m = new Map<string, Repo>()
|
||||
|
|
@ -49,7 +62,7 @@ const WorktreeList = React.memo(function WorktreeList() {
|
|||
}
|
||||
|
||||
// Filter active only
|
||||
if (showActiveOnly) {
|
||||
if (showActiveOnly && tabsByWorktree) {
|
||||
all = all.filter((w) => {
|
||||
const tabs = tabsByWorktree[w.id] ?? []
|
||||
return tabs.some((t) => t.ptyId)
|
||||
|
|
@ -77,43 +90,10 @@ const WorktreeList = React.memo(function WorktreeList() {
|
|||
return all
|
||||
}, [worktreesByRepo, filterRepoId, searchQuery, showActiveOnly, sortBy, repoMap, tabsByWorktree])
|
||||
|
||||
// Group
|
||||
const groups = useMemo(() => {
|
||||
if (groupBy === 'none') {
|
||||
return [{ label: null, items: worktrees }]
|
||||
}
|
||||
// Collapsed group state
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
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 prEntry = cacheKey ? prCache[cacheKey] : undefined
|
||||
const pr = prEntry !== undefined ? prEntry.data : 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) => {
|
||||
const toggleGroup = useCallback((label: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(label)) next.delete(label)
|
||||
|
|
@ -122,6 +102,61 @@ const WorktreeList = React.memo(function WorktreeList() {
|
|||
})
|
||||
}, [])
|
||||
|
||||
// Build flat row list for virtualizer
|
||||
const rows: Row[] = useMemo(() => {
|
||||
const result: Row[] = []
|
||||
|
||||
if (groupBy === 'none') {
|
||||
for (const w of worktrees) {
|
||||
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Group items
|
||||
const grouped = new Map<string, Worktree[]>()
|
||||
for (const w of worktrees) {
|
||||
let label: string
|
||||
if (groupBy === 'repo') {
|
||||
label = repoMap.get(w.repoId)?.displayName ?? 'Unknown'
|
||||
} else {
|
||||
// pr-status
|
||||
const repo = repoMap.get(w.repoId)
|
||||
const branch = branchName(w.branch)
|
||||
const cacheKey = repo ? `${repo.path}::${branch}` : ''
|
||||
const prEntry = cacheKey && prCache ? prCache[cacheKey] : undefined
|
||||
const pr = prEntry !== undefined ? prEntry.data : undefined
|
||||
label = pr ? pr.state.charAt(0).toUpperCase() + pr.state.slice(1) : 'No PR'
|
||||
}
|
||||
if (!grouped.has(label)) grouped.set(label, [])
|
||||
grouped.get(label)!.push(w)
|
||||
}
|
||||
|
||||
for (const [label, items] of grouped) {
|
||||
const isCollapsed = collapsedGroups.has(label)
|
||||
result.push({ type: 'header', label, count: items.length })
|
||||
if (!isCollapsed) {
|
||||
for (const w of items) {
|
||||
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}, [groupBy, worktrees, repoMap, prCache, collapsedGroups])
|
||||
|
||||
// ── TanStack Virtual ──────────────────────────────────────────
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (index) => (rows[index].type === 'header' ? 28 : 56),
|
||||
overscan: 10,
|
||||
getItemKey: (index) => {
|
||||
const row = rows[index]
|
||||
return row.type === 'header' ? `hdr:${row.label}` : `wt:${row.worktree.id}`
|
||||
}
|
||||
})
|
||||
|
||||
if (worktrees.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-6 text-center text-[11px] text-muted-foreground">
|
||||
|
|
@ -131,42 +166,56 @@ const WorktreeList = React.memo(function WorktreeList() {
|
|||
}
|
||||
|
||||
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
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto px-1 scrollbar-sleek">
|
||||
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const row = rows[vItem.index]
|
||||
|
||||
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!)}
|
||||
if (row.type === 'header') {
|
||||
return (
|
||||
<div
|
||||
key={vItem.key}
|
||||
data-index={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 right-0"
|
||||
style={{ transform: `translateY(${vItem.start}px)` }}
|
||||
>
|
||||
<span
|
||||
className="inline-block transition-transform text-[8px]"
|
||||
style={{ transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
|
||||
<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(row.label)}
|
||||
>
|
||||
▼
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
<span
|
||||
className="inline-block transition-transform text-[8px]"
|
||||
style={{
|
||||
transform: collapsedGroups.has(row.label) ? 'rotate(-90deg)' : 'rotate(0deg)'
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
{row.label}
|
||||
<span className="ml-auto text-[9px] font-normal tabular-nums">{row.count}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={vItem.key}
|
||||
data-index={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 right-0"
|
||||
style={{ transform: `translateY(${vItem.start}px)` }}
|
||||
>
|
||||
<WorktreeCard
|
||||
worktree={row.worktree}
|
||||
repo={row.repo}
|
||||
isActive={activeWorktreeId === row.worktree.id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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'
|
||||
|
|
@ -72,19 +71,19 @@ export default function Sidebar(): React.JSX.Element {
|
|||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div
|
||||
className="relative flex-shrink-0 bg-sidebar flex flex-col overflow-hidden transition-[width] duration-200"
|
||||
className="relative flex-shrink-0 bg-sidebar flex flex-col overflow-hidden transition-[width] duration-200 scrollbar-sleek-parent"
|
||||
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 controls */}
|
||||
<SidebarHeader />
|
||||
<SearchBar />
|
||||
<GroupControls />
|
||||
|
||||
{/* Virtualized scrollable list */}
|
||||
<WorktreeList />
|
||||
|
||||
{/* Fixed bottom toolbar */}
|
||||
<SidebarToolbar />
|
||||
|
|
|
|||
52
src/renderer/src/lib/agent-status.ts
Normal file
52
src/renderer/src/lib/agent-status.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export type AgentStatus = 'working' | 'permission' | 'idle'
|
||||
|
||||
const GEMINI_WORKING = '\u2726' // ✦
|
||||
const GEMINI_IDLE = '\u25C7' // ◇
|
||||
const GEMINI_PERMISSION = '\u270B' // ✋
|
||||
|
||||
const AGENT_NAMES = ['claude', 'codex', 'gemini', 'opencode', 'aider']
|
||||
|
||||
function containsBrailleSpinner(title: string): boolean {
|
||||
for (const char of title) {
|
||||
const codePoint = char.codePointAt(0)
|
||||
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function containsAgentName(title: string): boolean {
|
||||
const lower = title.toLowerCase()
|
||||
return AGENT_NAMES.some((name) => lower.includes(name))
|
||||
}
|
||||
|
||||
function containsAny(title: string, words: string[]): boolean {
|
||||
const lower = title.toLowerCase()
|
||||
return words.some((word) => lower.includes(word))
|
||||
}
|
||||
|
||||
export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
|
||||
if (!title) return null
|
||||
|
||||
// Gemini CLI symbols are the most specific and should take precedence.
|
||||
if (title.includes(GEMINI_PERMISSION)) return 'permission'
|
||||
if (title.includes(GEMINI_WORKING)) return 'working'
|
||||
if (title.includes(GEMINI_IDLE)) return 'idle'
|
||||
|
||||
if (containsBrailleSpinner(title)) return 'working'
|
||||
|
||||
if (containsAgentName(title)) {
|
||||
if (containsAny(title, ['action required', 'permission', 'waiting'])) return 'permission'
|
||||
if (containsAny(title, ['ready', 'idle', 'done'])) return 'idle'
|
||||
if (containsAny(title, ['working', 'thinking', 'running'])) return 'working'
|
||||
|
||||
// Claude Code title prefixes: ". " = working, "* " = idle
|
||||
if (title.startsWith('. ')) return 'working'
|
||||
if (title.startsWith('* ')) return 'idle'
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -7,23 +7,52 @@ export interface CacheEntry<T> {
|
|||
fetchedAt: number
|
||||
}
|
||||
|
||||
const CACHE_TTL = 60_000 // 60 seconds
|
||||
const CACHE_TTL = 300_000 // 5 minutes (stale data shown instantly, then refreshed)
|
||||
|
||||
function isFresh<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> {
|
||||
return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function debouncedSaveCache(state: AppState): void {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null
|
||||
window.api.cache.setGitHub({
|
||||
cache: {
|
||||
pr: state.prCache,
|
||||
issue: state.issueCache
|
||||
}
|
||||
})
|
||||
}, 1000) // Save at most once per second
|
||||
}
|
||||
|
||||
export interface GitHubSlice {
|
||||
prCache: Record<string, CacheEntry<PRInfo>>
|
||||
issueCache: Record<string, CacheEntry<IssueInfo>>
|
||||
fetchPRForBranch: (repoPath: string, branch: string) => Promise<PRInfo | null>
|
||||
fetchIssue: (repoPath: string, number: number) => Promise<IssueInfo | null>
|
||||
initGitHubCache: () => Promise<void>
|
||||
}
|
||||
|
||||
export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
|
||||
prCache: {},
|
||||
issueCache: {},
|
||||
|
||||
initGitHubCache: async () => {
|
||||
try {
|
||||
const persisted = await window.api.cache.getGitHub()
|
||||
if (persisted) {
|
||||
set({
|
||||
prCache: persisted.pr || {},
|
||||
issueCache: persisted.issue || {}
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load GitHub cache from disk:', err)
|
||||
}
|
||||
},
|
||||
|
||||
fetchPRForBranch: async (repoPath, branch) => {
|
||||
const cacheKey = `${repoPath}::${branch}`
|
||||
const cached = get().prCache[cacheKey]
|
||||
|
|
@ -34,12 +63,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
set((s) => ({
|
||||
prCache: { ...s.prCache, [cacheKey]: { data: pr, fetchedAt: Date.now() } }
|
||||
}))
|
||||
debouncedSaveCache(get())
|
||||
return pr
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch PR:', err)
|
||||
set((s) => ({
|
||||
prCache: { ...s.prCache, [cacheKey]: { data: null, fetchedAt: Date.now() } }
|
||||
}))
|
||||
debouncedSaveCache(get())
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
|
@ -54,12 +85,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
set((s) => ({
|
||||
issueCache: { ...s.issueCache, [cacheKey]: { data: issue, fetchedAt: Date.now() } }
|
||||
}))
|
||||
debouncedSaveCache(get())
|
||||
return issue
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch issue:', err)
|
||||
set((s) => ({
|
||||
issueCache: { ...s.issueCache, [cacheKey]: { data: null, fetchedAt: Date.now() } }
|
||||
}))
|
||||
debouncedSaveCache(get())
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
|
|||
lastActiveRepoId: null,
|
||||
lastActiveWorktreeId: null,
|
||||
sidebarWidth: 280
|
||||
}
|
||||
},
|
||||
githubCache: { pr: {}, issue: {} }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,4 +101,8 @@ export interface PersistedState {
|
|||
lastActiveWorktreeId: string | null
|
||||
sidebarWidth: number
|
||||
}
|
||||
githubCache: {
|
||||
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
|
||||
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue