merge: resolve conflict with main's cross-platform modifier key changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jinjing
2026-03-25 09:52:28 -07:00
co-authored by Claude Opus 4.6
14 changed files with 1004 additions and 79 deletions
+8
View File
@@ -3,3 +3,11 @@
## Worktree Safety
Always use the primary working directory (the worktree) for all file reads and edits. Never follow absolute paths from subagent results that point to the main repo.
## Cross-Platform Support
Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior behind runtime checks:
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.0.40",
"version": "1.0.41",
"description": "An Electron application with React and TypeScript",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+90 -1
View File
@@ -1,6 +1,6 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import type { PRInfo, IssueInfo, CheckStatus } from '../../shared/types'
import type { PRInfo, IssueInfo, CheckStatus, PRCheckDetail } from '../../shared/types'
const execFileAsync = promisify(execFile)
@@ -127,6 +127,95 @@ export async function listIssues(repoPath: string, limit = 20): Promise<IssueInf
}
}
/**
* Get detailed check statuses for a PR by number.
*/
export async function getPRChecks(repoPath: string, prNumber: number): Promise<PRCheckDetail[]> {
await acquire()
try {
const { stdout } = await execFileAsync(
'gh',
['pr', 'checks', String(prNumber), '--json', 'name,state,link'],
{
cwd: repoPath,
encoding: 'utf-8'
}
)
const data = JSON.parse(stdout) as { name: string; state: string; link: string }[]
return data.map((d) => ({
name: d.name,
status: mapCheckStatus(d.state),
conclusion: mapCheckConclusion(d.state),
url: d.link || null
}))
} catch (err) {
console.warn('getPRChecks failed:', err)
return []
} finally {
release()
}
}
/**
* Update a PR's title.
*/
export async function updatePRTitle(
repoPath: string,
prNumber: number,
title: string
): Promise<boolean> {
await acquire()
try {
await execFileAsync('gh', ['pr', 'edit', String(prNumber), '--title', title], {
cwd: repoPath,
encoding: 'utf-8'
})
return true
} catch (err) {
console.warn('updatePRTitle failed:', err)
return false
} finally {
release()
}
}
function mapCheckStatus(state: string): PRCheckDetail['status'] {
const s = state?.toUpperCase()
if (s === 'PENDING' || s === 'QUEUED') {
return 'queued'
}
if (s === 'IN_PROGRESS') {
return 'in_progress'
}
return 'completed'
}
function mapCheckConclusion(state: string): PRCheckDetail['conclusion'] {
const s = state?.toUpperCase()
if (s === 'SUCCESS' || s === 'PASS') {
return 'success'
}
if (s === 'FAILURE' || s === 'FAIL') {
return 'failure'
}
if (s === 'CANCELLED') {
return 'cancelled'
}
if (s === 'TIMED_OUT') {
return 'timed_out'
}
if (s === 'SKIPPED') {
return 'skipped'
}
if (s === 'PENDING' || s === 'QUEUED' || s === 'IN_PROGRESS') {
return 'pending'
}
if (s === 'NEUTRAL') {
return 'neutral'
}
return null
}
export function mapPRState(state: string): PRInfo['state'] {
const s = state?.toUpperCase()
if (s === 'MERGED') {
+12 -1
View File
@@ -1,5 +1,5 @@
import { ipcMain } from 'electron'
import { getPRForBranch, getIssue, listIssues } from '../github/client'
import { getPRForBranch, getIssue, listIssues, getPRChecks, updatePRTitle } from '../github/client'
export function registerGitHubHandlers(): void {
ipcMain.handle('gh:prForBranch', (_event, args: { repoPath: string; branch: string }) => {
@@ -13,4 +13,15 @@ export function registerGitHubHandlers(): void {
ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => {
return listIssues(args.repoPath, args.limit)
})
ipcMain.handle('gh:prChecks', (_event, args: { repoPath: string; prNumber: number }) => {
return getPRChecks(args.repoPath, args.prNumber)
})
ipcMain.handle(
'gh:updatePRTitle',
(_event, args: { repoPath: string; prNumber: number; title: string }) => {
return updatePRTitle(args.repoPath, args.prNumber, args.title)
}
)
}
+3
View File
@@ -4,6 +4,7 @@ import type {
Worktree,
WorktreeMeta,
PRInfo,
PRCheckDetail,
IssueInfo,
GlobalSettings,
OrcaHooks,
@@ -54,6 +55,8 @@ type 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[]>
prChecks: (args: { repoPath: string; prNumber: number }) => Promise<PRCheckDetail[]>
updatePRTitle: (args: { repoPath: string; prNumber: number; title: string }) => Promise<boolean>
}
type SettingsApi = {
+10 -1
View File
@@ -140,7 +140,16 @@ const api = {
ipcRenderer.invoke('gh:issue', args),
listIssues: (args: { repoPath: string; limit?: number }): Promise<unknown[]> =>
ipcRenderer.invoke('gh:listIssues', args)
ipcRenderer.invoke('gh:listIssues', args),
prChecks: (args: { repoPath: string; prNumber: number }): Promise<unknown[]> =>
ipcRenderer.invoke('gh:prChecks', args),
updatePRTitle: (args: {
repoPath: string
prNumber: number
title: string
}): Promise<boolean> => ipcRenderer.invoke('gh:updatePRTitle', args)
},
settings: {
+11 -9
View File
@@ -214,12 +214,14 @@ function App(): React.JSX.Element {
if (e.repeat) {
return
}
if (!e.metaKey) {
// Accept Cmd on macOS, Ctrl on other platforms
const mod = navigator.userAgent.includes('Mac') ? e.metaKey : e.ctrlKey
if (!mod) {
return
}
// Cmd+N — create worktree
if (!e.ctrlKey && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'n') {
// Cmd/Ctrl+N — create worktree
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'n') {
if (repos.length === 0) {
return
}
@@ -228,24 +230,24 @@ function App(): React.JSX.Element {
return
}
// Cmd+Shift+E — toggle right sidebar / explorer tab
if (e.shiftKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'e') {
// Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'e') {
e.preventDefault()
setRightSidebarTab('explorer')
setRightSidebarOpen(true)
return
}
// Cmd+Shift+F — toggle right sidebar / search tab
if (e.shiftKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'f') {
// Cmd/Ctrl+Shift+F — toggle right sidebar / search tab
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'f') {
e.preventDefault()
setRightSidebarTab('search')
setRightSidebarOpen(true)
return
}
// Cmd+Shift+G — toggle right sidebar / source control tab
if (e.shiftKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'g') {
// Cmd/Ctrl+Shift+G — toggle right sidebar / source control tab
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'g') {
e.preventDefault()
setRightSidebarTab('source-control')
setRightSidebarOpen(true)
+427 -59
View File
@@ -1,62 +1,408 @@
import { lazy, Suspense } from 'react'
import { useEffect, useCallback, useRef, useState, lazy, Suspense } from 'react'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { useAppStore } from '../store'
import { TerminalShell } from './terminal/TerminalShell'
import { useTerminalSaveDialog } from './terminal/useTerminalSaveDialog'
import { useTerminalShortcuts } from './terminal/useTerminalShortcuts'
import { useTerminalTabs } from './terminal/useTerminalTabs'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import TabBar from './tab-bar/TabBar'
import TerminalPane from './terminal-pane/TerminalPane'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
export default function Terminal(): React.JSX.Element | null {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeView = useAppStore((s) => s.activeView)
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 setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle)
const setTabColor = useAppStore((s) => s.setTabColor)
const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const openFiles = useAppStore((s) => s.openFiles)
const activeFileId = useAppStore((s) => s.activeFileId)
const activeTabType = useAppStore((s) => s.activeTabType)
const setActiveTabType = useAppStore((s) => s.setActiveTabType)
const setActiveFile = useAppStore((s) => s.setActiveFile)
const closeFile = useAppStore((s) => s.closeFile)
const closeAllFiles = useAppStore((s) => s.closeAllFiles)
const markFileDirty = useAppStore((s) => s.markFileDirty)
const saveDialog = useTerminalSaveDialog({ openFiles, closeFile, markFileDirty })
const terminalTabs = useTerminalTabs()
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
const allWorktrees = Object.values(worktreesByRepo).flat()
useTerminalShortcuts({
activeWorktreeId: terminalTabs.activeWorktreeId,
activeTabId: terminalTabs.activeTabId,
activeFileId: terminalTabs.activeFileId,
activeTabType: terminalTabs.activeTabType,
unifiedTabs: terminalTabs.unifiedTabs,
hasDirtyFiles: openFiles.some((file) => file.isDirty),
onNewTab: terminalTabs.handleNewTab,
onCloseTab: terminalTabs.handleCloseTab,
onCloseFile: saveDialog.requestCloseFile,
onActivateTerminalTab: terminalTabs.handleActivateTab,
onActivateEditorTab: terminalTabs.handleActivateFile
})
// Filter editor files to only show those belonging to the active worktree
const worktreeFiles = activeWorktreeId
? openFiles.filter((f) => f.worktreeId === activeWorktreeId)
: []
// Save confirmation dialog state
const [saveDialogFileId, setSaveDialogFileId] = useState<string | null>(null)
const saveDialogFile = saveDialogFileId ? openFiles.find((f) => f.id === saveDialogFileId) : null
const handleCloseFile = useCallback(
(fileId: string) => {
const file = useAppStore.getState().openFiles.find((f) => f.id === fileId)
if (file?.isDirty) {
setSaveDialogFileId(fileId)
return
}
closeFile(fileId)
},
[closeFile]
)
const handleSaveDialogSave = useCallback(async () => {
if (!saveDialogFileId) {
return
}
const file = useAppStore.getState().openFiles.find((f) => f.id === saveDialogFileId)
if (!file) {
return
}
// EditorPanel stores edit buffers internally — we need to read the current content from the editor.
// The simplest approach: dispatch a custom event that the MonacoEditor listens for to trigger save,
// then close. But that's complex. Instead, just save via the editor ref approach.
// Actually, we can read the current content from the DOM's Monaco instance.
// Simpler: just close without saving is "Don't Save", and save is handled by a custom event.
// For now, trigger a save event that EditorPanel listens for.
window.dispatchEvent(
new CustomEvent('orca:save-and-close', { detail: { fileId: saveDialogFileId } })
)
setSaveDialogFileId(null)
}, [saveDialogFileId])
const handleSaveDialogDiscard = useCallback(() => {
if (!saveDialogFileId) {
return
}
markFileDirty(saveDialogFileId, false)
closeFile(saveDialogFileId)
setSaveDialogFileId(null)
}, [saveDialogFileId, closeFile, markFileDirty])
const handleSaveDialogCancel = useCallback(() => {
setSaveDialogFileId(null)
}, [])
// Ensure activeTabId is valid (adjusting state during render)
if (tabs.length > 0 && (!activeTabId || !tabs.find((t) => t.id === activeTabId))) {
setActiveTab(tabs[0].id)
}
// Track which worktrees have been activated during this app session.
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
// spawning when restoring a session with many saved worktree tabs.
const mountedWorktreeIdsRef = useRef(new Set<string>())
if (activeWorktreeId) {
mountedWorktreeIdsRef.current.add(activeWorktreeId)
}
// Prune IDs of worktrees that no longer exist (deleted/removed)
const allWorktreeIds = new Set(allWorktrees.map((wt) => wt.id))
for (const id of mountedWorktreeIdsRef.current) {
if (!allWorktreeIds.has(id)) {
mountedWorktreeIdsRef.current.delete(id)
}
}
const initialTabCreationGuardRef = useRef<string | null>(null)
// Auto-create first tab when worktree activates
useEffect(() => {
if (!workspaceSessionReady) {
return
}
if (!activeWorktreeId) {
initialTabCreationGuardRef.current = null
return
}
if (tabs.length > 0) {
if (initialTabCreationGuardRef.current === activeWorktreeId) {
initialTabCreationGuardRef.current = null
}
return
}
// In React StrictMode (dev), mount effects are intentionally invoked twice.
// Track the worktree we already initialized so we only create one first tab.
if (initialTabCreationGuardRef.current === activeWorktreeId) {
return
}
initialTabCreationGuardRef.current = activeWorktreeId
createTab(activeWorktreeId)
}, [workspaceSessionReady, activeWorktreeId, tabs.length, createTab])
const totalTabs = tabs.length + worktreeFiles.length
const handleNewTab = useCallback(() => {
if (!activeWorktreeId) {
return
}
createTab(activeWorktreeId)
}, [activeWorktreeId, createTab])
const handleCloseTab = useCallback(
(tabId: string) => {
const state = useAppStore.getState()
const owningWorktreeEntry = Object.entries(state.tabsByWorktree).find(([, worktreeTabs]) =>
worktreeTabs.some((tab) => tab.id === tabId)
)
const owningWorktreeId = owningWorktreeEntry?.[0] ?? null
if (!owningWorktreeId) {
return
}
const currentTabs = state.tabsByWorktree[owningWorktreeId] ?? []
if (currentTabs.length <= 1) {
// Last tab in this worktree. Only clear the active worktree if this
// tab belongs to the currently focused worktree.
closeTab(tabId)
if (state.activeWorktreeId === owningWorktreeId) {
setActiveWorktree(null)
}
return
}
// If closing the active tab in the active worktree, switch to a neighbor.
if (state.activeWorktreeId === owningWorktreeId && tabId === state.activeTabId) {
const idx = currentTabs.findIndex((t) => t.id === tabId)
const nextTab = currentTabs[idx + 1] ?? currentTabs[idx - 1]
if (nextTab) {
setActiveTab(nextTab.id)
}
}
closeTab(tabId)
},
[closeTab, setActiveTab, setActiveWorktree]
)
const handlePtyExit = useCallback(
(tabId: string, ptyId: string) => {
if (consumeSuppressedPtyExit(ptyId)) {
return
}
handleCloseTab(tabId)
},
[consumeSuppressedPtyExit, handleCloseTab]
)
const handleCloseOthers = useCallback(
(tabId: string) => {
if (!activeWorktreeId) {
return
}
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
setActiveTab(tabId)
for (const tab of currentTabs) {
if (tab.id !== tabId) {
closeTab(tab.id)
}
}
},
[activeWorktreeId, closeTab, setActiveTab]
)
const handleCloseTabsToRight = useCallback(
(tabId: string) => {
if (!activeWorktreeId) {
return
}
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
const index = currentTabs.findIndex((t) => t.id === tabId)
if (index === -1) {
return
}
const rightTabs = currentTabs.slice(index + 1)
for (const tab of rightTabs) {
closeTab(tab.id)
}
},
[activeWorktreeId, closeTab]
)
const handleActivateTab = useCallback(
(tabId: string) => {
setActiveTab(tabId)
setActiveTabType('terminal')
},
[setActiveTab, setActiveTabType]
)
const handleTogglePaneExpand = useCallback(
(tabId: string) => {
setActiveTab(tabId)
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, {
detail: { tabId }
})
)
})
},
[setActiveTab]
)
// Keyboard shortcuts
useEffect(() => {
if (!activeWorktreeId) {
return
}
const isMac = navigator.userAgent.includes('Mac')
const onKeyDown = (e: KeyboardEvent): void => {
const mod = isMac ? e.metaKey : e.ctrlKey
// Cmd/Ctrl+T - new tab
if (mod && e.key === 't' && !e.shiftKey && !e.repeat) {
e.preventDefault()
handleNewTab()
return
}
// Cmd/Ctrl+W - close active tab
if (mod && e.key === 'w' && !e.shiftKey && !e.repeat) {
e.preventDefault()
const state = useAppStore.getState()
if (state.activeTabType === 'editor' && state.activeFileId) {
handleCloseFile(state.activeFileId)
} else if (state.activeTabId) {
handleCloseTab(state.activeTabId)
}
return
}
// Cmd/Ctrl+Shift+] and Cmd/Ctrl+Shift+[ - switch tabs
if (mod && e.shiftKey && (e.key === ']' || e.key === '[') && !e.repeat) {
const state = useAppStore.getState()
const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? []
const currentEditorFiles = activeWorktreeId
? state.openFiles.filter((f) => f.worktreeId === activeWorktreeId)
: []
// Build unified tab list: terminal tabs then editor tabs
const allTabIds: { type: 'terminal' | 'editor'; id: string }[] = [
...currentTerminalTabs.map((t) => ({ type: 'terminal' as const, id: t.id })),
...currentEditorFiles.map((f) => ({ type: 'editor' as const, id: f.id }))
]
if (allTabIds.length > 1) {
e.preventDefault()
const currentId =
state.activeTabType === 'editor' ? state.activeFileId : state.activeTabId
const idx = allTabIds.findIndex((t) => t.id === currentId)
const dir = e.key === ']' ? 1 : -1
const next = allTabIds[(idx + dir + allTabIds.length) % allTabIds.length]
if (next.type === 'terminal') {
setActiveTab(next.id)
state.setActiveTabType('terminal')
} else {
state.setActiveFile(next.id)
state.setActiveTabType('editor')
}
}
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [activeWorktreeId, handleNewTab, handleCloseTab, handleCloseFile, setActiveTab])
// Warn on window close if there are unsaved editor files
useEffect(() => {
const handler = (e: BeforeUnloadEvent): void => {
const dirtyFiles = useAppStore.getState().openFiles.filter((f) => f.isDirty)
if (dirtyFiles.length > 0) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [])
return (
<TerminalShell
activeWorktreeId={terminalTabs.activeWorktreeId}
activeView={terminalTabs.activeView}
totalTabs={terminalTabs.totalTabs}
tabs={terminalTabs.tabs}
activeTabId={terminalTabs.activeTabId}
activeFileId={terminalTabs.activeFileId}
activeTabType={terminalTabs.activeTabType}
expandedPaneByTabId={terminalTabs.expandedPaneByTabId}
worktreeFiles={terminalTabs.worktreeFiles}
mountedWorktrees={terminalTabs.mountedWorktrees}
tabsByWorktree={terminalTabs.tabsByWorktree}
onActivateTab={terminalTabs.handleActivateTab}
onCloseTab={terminalTabs.handleCloseTab}
onCloseOthers={terminalTabs.handleCloseOthers}
onCloseTabsToRight={terminalTabs.handleCloseTabsToRight}
onReorderTabs={terminalTabs.setTabBarOrder}
onNewTab={terminalTabs.handleNewTab}
onSetCustomTitle={terminalTabs.setTabCustomTitle}
onSetTabColor={terminalTabs.setTabColor}
onTogglePaneExpand={terminalTabs.handleTogglePaneExpand}
onActivateFile={terminalTabs.handleActivateFile}
onCloseFile={saveDialog.requestCloseFile}
onCloseAllFiles={terminalTabs.closeAllFiles}
onPtyExit={terminalTabs.handlePtyExit}
tabBarOrder={terminalTabs.tabBarOrder}
editorPanel={
<div
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${activeWorktreeId ? '' : ' hidden'}`}
>
{/* Animated tab bar container using CSS grid for smooth height animation */}
<div
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
style={{ gridTemplateRows: activeWorktreeId && totalTabs >= 2 ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
{activeWorktreeId && (
<TabBar
tabs={tabs}
activeTabId={activeTabId}
worktreeId={activeWorktreeId}
onActivate={handleActivateTab}
onClose={handleCloseTab}
onCloseOthers={handleCloseOthers}
onCloseToRight={handleCloseTabsToRight}
onReorder={reorderTabs}
onNewTab={handleNewTab}
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}
expandedPaneByTabId={expandedPaneByTabId}
onTogglePaneExpand={handleTogglePaneExpand}
editorFiles={worktreeFiles}
activeFileId={activeFileId}
activeTabType={activeTabType}
onActivateFile={(fileId) => {
setActiveFile(fileId)
setActiveTabType('editor')
}}
onCloseFile={handleCloseFile}
onCloseAllFiles={closeAllFiles}
/>
)}
</div>
</div>
{/* Terminal panes container - hidden when editor tab active */}
<div
className={`relative flex-1 min-h-0 overflow-hidden ${activeTabType === 'editor' && worktreeFiles.length > 0 ? 'hidden' : ''}`}
>
{allWorktrees
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
.map((worktree) => {
const worktreeTabs = tabsByWorktree[worktree.id] ?? []
const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
return (
<div
key={worktree.id}
className={isVisible ? 'absolute inset-0' : 'absolute inset-0 hidden'}
aria-hidden={!isVisible}
>
{worktreeTabs.map((tab) => (
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
tabId={tab.id}
worktreeId={worktree.id}
cwd={worktree.path}
isActive={isVisible && tab.id === activeTabId}
onPtyExit={(ptyId) => handlePtyExit(tab.id, ptyId)}
/>
))}
</div>
)
})}
</div>
{/* Editor panel - shown when editor tab is active */}
{activeWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
@@ -66,17 +412,39 @@ export default function Terminal(): React.JSX.Element | null {
>
<EditorPanel />
</Suspense>
}
saveDialogFileId={saveDialog.saveDialogFileId}
saveDialogFile={saveDialog.saveDialogFile}
onSaveDialogOpenChange={(open) => {
if (!open) {
saveDialog.handleSaveDialogCancel()
}
}}
onSaveDialogCancel={saveDialog.handleSaveDialogCancel}
onSaveDialogDiscard={saveDialog.handleSaveDialogDiscard}
onSaveDialogSave={saveDialog.handleSaveDialogSave}
/>
)}
{/* Save confirmation dialog */}
<Dialog
open={saveDialogFileId !== null}
onOpenChange={(open) => {
if (!open) {
handleSaveDialogCancel()
}
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm">Unsaved Changes</DialogTitle>
<DialogDescription className="text-xs">
{saveDialogFile
? `"${saveDialogFile.relativePath.split('/').pop()}" has unsaved changes. Do you want to save before closing?`
: 'This file has unsaved changes.'}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" size="sm" onClick={handleSaveDialogCancel}>
Cancel
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleSaveDialogDiscard}>
Don&apos;t Save
</Button>
<Button type="button" size="sm" onClick={handleSaveDialogSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,394 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
CircleCheck,
CircleX,
LoaderCircle,
CircleDashed,
CircleMinus,
ExternalLink,
RefreshCw,
Check,
X,
Pencil
} from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import type { PRInfo, PRCheckDetail } from '../../../../shared/types'
function PullRequestIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden fill="currentColor" className={className}>
<path
fillRule="evenodd"
d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.25 2.25 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1.5 1.5 0 011.5 1.5v5.628a2.25 2.25 0 101.5 0V5.5A3 3 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"
/>
</svg>
)
}
const CHECK_ICON: Record<string, React.ComponentType<{ className?: string }>> = {
success: CircleCheck,
failure: CircleX,
pending: LoaderCircle,
neutral: CircleDashed,
skipped: CircleMinus,
cancelled: CircleX,
timed_out: CircleX
}
const CHECK_COLOR: Record<string, string> = {
success: 'text-emerald-500',
failure: 'text-rose-500',
pending: 'text-amber-500',
neutral: 'text-muted-foreground',
skipped: 'text-muted-foreground/60',
cancelled: 'text-muted-foreground/60',
timed_out: 'text-rose-500'
}
function prStateColor(state: PRInfo['state']): string {
switch (state) {
case 'merged':
return 'bg-purple-500/15 text-purple-500 border-purple-500/20'
case 'open':
return 'bg-emerald-500/15 text-emerald-500 border-emerald-500/20'
case 'closed':
return 'bg-muted text-muted-foreground border-border'
case 'draft':
return 'bg-muted text-muted-foreground/70 border-border'
}
}
export default function ChecksPanel(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const repos = useAppStore((s) => s.repos)
const prCache = useAppStore((s) => s.prCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const [checks, setChecks] = useState<PRCheckDetail[]>([])
const [checksLoading, setChecksLoading] = useState(false)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
const [titleSaving, setTitleSaving] = useState(false)
const titleInputRef = useRef<HTMLInputElement>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Find active worktree and repo
const { worktree, repo } = useMemo(() => {
if (!activeWorktreeId) {
return { worktree: null, repo: null }
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === activeWorktreeId)
if (wt) {
const r = repos.find((rp) => rp.id === wt.repoId)
return { worktree: wt, repo: r ?? null }
}
}
return { worktree: null, repo: null }
}, [activeWorktreeId, worktreesByRepo, repos])
const branch = worktree ? worktree.branch.replace(/^refs\/heads\//, '') : ''
const prCacheKey = repo && branch ? `${repo.path}::${branch}` : ''
const pr: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null
// Fetch checks
const fetchChecks = useCallback(async () => {
if (!repo || !pr) {
return
}
setChecksLoading(true)
try {
const result = (await window.api.gh.prChecks({
repoPath: repo.path,
prNumber: pr.number
})) as PRCheckDetail[]
setChecks(result)
} catch (err) {
console.warn('Failed to fetch PR checks:', err)
setChecks([])
} finally {
setChecksLoading(false)
}
}, [repo, pr])
// Fetch checks on mount + poll
useEffect(() => {
if (!pr) {
setChecks([])
return
}
void fetchChecks()
pollRef.current = setInterval(() => void fetchChecks(), 30_000)
return () => {
if (pollRef.current) {
clearInterval(pollRef.current)
}
}
}, [fetchChecks, pr])
const handleRefresh = useCallback(async () => {
if (!repo || !branch) {
return
}
// Refresh PR data + checks
await fetchPRForBranch(repo.path, branch)
await fetchChecks()
}, [repo, branch, fetchPRForBranch, fetchChecks])
const handleStartEdit = useCallback(() => {
if (!pr) {
return
}
setTitleDraft(pr.title)
setEditingTitle(true)
setTimeout(() => titleInputRef.current?.focus(), 0)
}, [pr])
const handleCancelEdit = useCallback(() => {
setEditingTitle(false)
setTitleDraft('')
}, [])
const handleSaveTitle = useCallback(async () => {
if (!repo || !pr || !titleDraft.trim() || titleDraft === pr.title) {
setEditingTitle(false)
return
}
setTitleSaving(true)
try {
const ok = await window.api.gh.updatePRTitle({
repoPath: repo.path,
prNumber: pr.number,
title: titleDraft.trim()
})
if (ok) {
// Re-fetch PR to get updated title
await fetchPRForBranch(repo.path, branch)
}
} finally {
setTitleSaving(false)
setEditingTitle(false)
}
}, [repo, pr, titleDraft, branch, fetchPRForBranch])
const handleTitleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
void handleSaveTitle()
} else if (e.key === 'Escape') {
handleCancelEdit()
}
},
[handleSaveTitle, handleCancelEdit]
)
// Open PR in browser
const handleOpenPR = useCallback(() => {
if (pr?.url) {
window.api.shell.openUrl(pr.url)
}
}, [pr])
// ── Empty state ──
if (!worktree) {
return (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground px-4 text-center">
Select a worktree to view PR checks
</div>
)
}
if (!pr) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 px-4 text-center">
<PullRequestIcon className="size-8 text-muted-foreground/30" />
<div className="text-[11px] text-muted-foreground">
No pull request found for this branch
</div>
<div className="text-[10px] text-muted-foreground/60">
Push your branch and open a PR to see checks here
</div>
</div>
)
}
// ── Sorted checks: failures first, then pending, then success ──
const sortedChecks = [...checks].sort((a, b) => {
const order = {
failure: 0,
timed_out: 0,
cancelled: 1,
pending: 2,
neutral: 3,
skipped: 4,
success: 5
}
const aOrder = order[a.conclusion ?? 'pending'] ?? 3
const bOrder = order[b.conclusion ?? 'pending'] ?? 3
return aOrder - bOrder
})
const passingCount = checks.filter((c) => c.conclusion === 'success').length
const failingCount = checks.filter(
(c) => c.conclusion === 'failure' || c.conclusion === 'timed_out'
).length
const pendingCount = checks.filter(
(c) => c.conclusion === 'pending' || c.conclusion === null
).length
return (
<div className="flex-1 overflow-auto scrollbar-sleek">
{/* PR Header */}
<div className="px-3 py-3 border-b border-border space-y-2.5">
{/* PR number + state badge + refresh + open link */}
<div className="flex items-center gap-2">
<PullRequestIcon className="size-4 text-muted-foreground shrink-0" />
<span className="text-[12px] font-semibold text-foreground">#{pr.number}</span>
<span
className={cn(
'text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border',
prStateColor(pr.state)
)}
>
{pr.state}
</span>
<div className="flex-1" />
<button
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Refresh"
onClick={() => void handleRefresh()}
>
<RefreshCw className={cn('size-3.5', checksLoading && 'animate-spin')} />
</button>
<button
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Open on GitHub"
onClick={handleOpenPR}
>
<ExternalLink className="size-3.5" />
</button>
</div>
{/* PR title (editable) */}
{editingTitle ? (
<div className="flex items-center gap-1">
<input
ref={titleInputRef}
className="flex-1 text-[12px] bg-background border border-border rounded px-2 py-1 text-foreground outline-none focus:ring-1 focus:ring-ring"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={handleTitleKeyDown}
disabled={titleSaving}
/>
<button
className="p-1 rounded hover:bg-accent text-emerald-500 hover:text-emerald-400 transition-colors"
title="Save"
onClick={() => void handleSaveTitle()}
disabled={titleSaving}
>
{titleSaving ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</button>
<button
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Cancel"
onClick={handleCancelEdit}
disabled={titleSaving}
>
<X className="size-3.5" />
</button>
</div>
) : (
<div
className="group/title flex items-start gap-1.5 cursor-pointer -mx-1 px-1 py-0.5 rounded hover:bg-accent/40 transition-colors"
onClick={handleStartEdit}
>
<span className="text-[12px] text-foreground leading-snug flex-1">{pr.title}</span>
<Pencil className="size-3 text-muted-foreground/40 opacity-0 group-hover/title:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
)}
{/* Updated at */}
{pr.updatedAt && (
<div className="text-[10px] text-muted-foreground/60">
Updated {new Date(pr.updatedAt).toLocaleString()}
</div>
)}
</div>
{/* Checks Summary */}
{checks.length > 0 && (
<div className="flex items-center gap-3 px-3 py-2 border-b border-border text-[10px] text-muted-foreground">
{passingCount > 0 && (
<span className="flex items-center gap-1">
<CircleCheck className="size-3 text-emerald-500" />
{passingCount} passing
</span>
)}
{failingCount > 0 && (
<span className="flex items-center gap-1">
<CircleX className="size-3 text-rose-500" />
{failingCount} failing
</span>
)}
{pendingCount > 0 && (
<span className="flex items-center gap-1">
<LoaderCircle className="size-3 text-amber-500" />
{pendingCount} pending
</span>
)}
</div>
)}
{/* Checks List */}
{checksLoading && checks.length === 0 ? (
<div className="flex items-center justify-center py-8">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
) : checks.length === 0 ? (
<div className="flex items-center justify-center py-8 text-[11px] text-muted-foreground">
No checks configured
</div>
) : (
<div className="py-1">
{sortedChecks.map((check) => {
const conclusion = check.conclusion ?? 'pending'
const Icon = CHECK_ICON[conclusion] ?? CircleDashed
const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground'
return (
<div
key={check.name}
className={cn(
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/40 transition-colors',
check.url && 'cursor-pointer'
)}
onClick={() => {
if (check.url) {
window.api.shell.openUrl(check.url)
}
}}
>
<Icon
className={cn(
'size-3.5 shrink-0',
color,
conclusion === 'pending' && 'animate-spin'
)}
/>
<span className="flex-1 truncate text-[12px] text-foreground">{check.name}</span>
{check.url && <ExternalLink className="size-3 text-muted-foreground/40 shrink-0" />}
</div>
)
})}
</div>
)}
</div>
)
}
@@ -1,5 +1,5 @@
import React, { useCallback, useRef, useEffect } from 'react'
import { Files, Search, GitBranch } from 'lucide-react'
import { Files, Search, GitBranch, ListChecks } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import type { RightSidebarTab, ActivityBarPosition } from '@/store/slices/editor'
@@ -15,6 +15,7 @@ import {
import FileExplorer from './FileExplorer'
import SourceControl from './SourceControl'
import SearchPanel from './Search'
import ChecksPanel from './ChecksPanel'
const MIN_WIDTH = 220
const MAX_WIDTH = 500
@@ -28,10 +29,29 @@ type ActivityBarItem = {
shortcut: string
}
const isMac = navigator.userAgent.includes('Mac')
const mod = isMac ? '\u2318' : 'Ctrl+'
const ACTIVITY_ITEMS: ActivityBarItem[] = [
{ id: 'explorer', icon: Files, title: 'Explorer', shortcut: '\u21E7\u2318E' },
{ id: 'source-control', icon: GitBranch, title: 'Source Control', shortcut: '\u21E7\u2318G' },
{ id: 'search', icon: Search, title: 'Search', shortcut: '\u21E7\u2318F' }
{
id: 'explorer',
icon: Files,
title: 'Explorer',
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}E`
},
{
id: 'source-control',
icon: GitBranch,
title: 'Source Control',
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}G`
},
{
id: 'checks',
icon: ListChecks,
title: 'Checks',
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}K`
},
{ id: 'search', icon: Search, title: 'Search', shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}F` }
]
export default function RightSidebar(): React.JSX.Element {
@@ -97,6 +117,7 @@ export default function RightSidebar(): React.JSX.Element {
{rightSidebarTab === 'explorer' && <FileExplorer key={activeWorktreeId ?? 'none'} />}
{rightSidebarTab === 'search' && <SearchPanel key={activeWorktreeId ?? 'none'} />}
{rightSidebarTab === 'source-control' && <SourceControl key={activeWorktreeId ?? 'none'} />}
{rightSidebarTab === 'checks' && <ChecksPanel key={activeWorktreeId ?? 'none'} />}
</div>
)
@@ -45,6 +45,7 @@ export function useTerminalKeyboardShortcuts({
return
}
const isMac = navigator.userAgent.includes('Mac')
const onKeyDown = (e: KeyboardEvent): void => {
if (e.repeat) {
return
@@ -52,7 +53,8 @@ export function useTerminalKeyboardShortcuts({
if (isEditableTarget(e.target)) {
return
}
if (!e.metaKey || e.altKey || e.ctrlKey) {
const mod = isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey
if (!mod || e.altKey) {
return
}
@@ -29,7 +29,10 @@ export function useTerminalShortcuts({
onActivateEditorTab
}: UseTerminalShortcutsParams): void {
const handleKeyDown = useEffectEvent((event: KeyboardEvent) => {
if (!activeWorktreeId || !event.metaKey || event.repeat) {
// Accept Cmd on macOS, Ctrl on other platforms
const isMac = navigator.userAgent.includes('Mac')
const mod = isMac ? event.metaKey : event.ctrlKey
if (!activeWorktreeId || !mod || event.repeat) {
return
}
+1 -1
View File
@@ -14,7 +14,7 @@ export type OpenFile = {
diffStaged?: boolean
}
export type RightSidebarTab = 'explorer' | 'search' | 'source-control'
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks'
export type ActivityBarPosition = 'top' | 'side'
export type EditorSlice = {
+15
View File
@@ -100,6 +100,21 @@ export type PRInfo = {
updatedAt: string
}
export type PRCheckDetail = {
name: string
status: 'queued' | 'in_progress' | 'completed'
conclusion:
| 'success'
| 'failure'
| 'cancelled'
| 'timed_out'
| 'neutral'
| 'skipped'
| 'pending'
| null
url: string | null
}
export type IssueInfo = {
number: number
title: string