From 0b9624ec70006c9a0370f0879aebd85bfd394e39 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:21:14 -0700 Subject: [PATCH 1/3] feat: linux-friendly keyboard shortcuts (#93) * feat: make keyboard shortcuts work with Ctrl on Linux/Windows All Cmd+X shortcuts now also respond to Ctrl+X on non-Mac platforms. Tooltip hints show platform-appropriate modifier symbols. Co-Authored-By: Claude Opus 4.6 * docs: add cross-platform guidelines to AGENTS.md Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- AGENTS.md | 8 + src/renderer/src/App.tsx | 20 +- src/renderer/src/components/Terminal.tsx | 486 +++++++++++++++--- .../src/components/right-sidebar/index.tsx | 19 +- .../terminal-pane/keyboard-handlers.ts | 4 +- .../terminal/useTerminalShortcuts.ts | 5 +- 6 files changed, 469 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c6bc1ef7fd3..9cafd6af355 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `\`. diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 625c62a0c65..953f0320277 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 57967963830..45790ae89c0 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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(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()) + 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(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 ( - + {/* Animated tab bar container using CSS grid for smooth height animation */} +
= 2 ? '1fr' : '0fr' }} + > +
+ {activeWorktreeId && ( + { + setActiveFile(fileId) + setActiveTabType('editor') + }} + onCloseFile={handleCloseFile} + onCloseAllFiles={closeAllFiles} + /> + )} +
+
+ + {/* Terminal panes container - hidden when editor tab active */} +
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 ( +
+ {worktreeTabs.map((tab) => ( + handlePtyExit(tab.id, ptyId)} + /> + ))} +
+ ) + })} +
+ + {/* Editor panel - shown when editor tab is active */} + {activeWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && ( @@ -66,17 +412,39 @@ export default function Terminal(): React.JSX.Element | null { > - } - saveDialogFileId={saveDialog.saveDialogFileId} - saveDialogFile={saveDialog.saveDialogFile} - onSaveDialogOpenChange={(open) => { - if (!open) { - saveDialog.handleSaveDialogCancel() - } - }} - onSaveDialogCancel={saveDialog.handleSaveDialogCancel} - onSaveDialogDiscard={saveDialog.handleSaveDialogDiscard} - onSaveDialogSave={saveDialog.handleSaveDialogSave} - /> + )} + + {/* Save confirmation dialog */} + { + if (!open) { + handleSaveDialogCancel() + } + }} + > + + + Unsaved Changes + + {saveDialogFile + ? `"${saveDialogFile.relativePath.split('/').pop()}" has unsaved changes. Do you want to save before closing?` + : 'This file has unsaved changes.'} + + + + + + + + + + ) } diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index 3fc920e89fc..93ca87793cc 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -28,10 +28,23 @@ 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: 'search', icon: Search, title: 'Search', shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}F` } ] export default function RightSidebar(): React.JSX.Element { diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 7ce428bd33a..f9290d9577e 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -32,11 +32,13 @@ export function useTerminalKeyboardShortcuts({ return } + const isMac = navigator.userAgent.includes('Mac') const onKeyDown = (e: KeyboardEvent): void => { if (e.repeat) { return } - if (!e.metaKey || e.altKey || e.ctrlKey) { + const mod = isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey + if (!mod || e.altKey) { return } diff --git a/src/renderer/src/components/terminal/useTerminalShortcuts.ts b/src/renderer/src/components/terminal/useTerminalShortcuts.ts index 82d6e9f092f..8d0ec2b4325 100644 --- a/src/renderer/src/components/terminal/useTerminalShortcuts.ts +++ b/src/renderer/src/components/terminal/useTerminalShortcuts.ts @@ -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 } From ddc8a787443de9f30f30d152ed6a967c82639c7c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:29:39 -0700 Subject: [PATCH 2/3] Display PR checks and title updates (#101) - Add PR checks panel to right sidebar - Stream checks from GitHub API - Update PR title real-time with loading state - Add error logging and dependency fixes - Fix linting issues (curly braces, exhaustive deps) --- src/main/github/client.ts | 91 +++- src/main/ipc/github.ts | 13 +- src/preload/index.d.ts | 3 + src/preload/index.ts | 11 +- .../components/right-sidebar/ChecksPanel.tsx | 394 ++++++++++++++++++ .../src/components/right-sidebar/index.tsx | 10 +- src/renderer/src/store/slices/editor.ts | 2 +- src/shared/types.ts | 15 + 8 files changed, 534 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ChecksPanel.tsx diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 982e6b8c439..28bd5097171 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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 { + 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 { + 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') { diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 243bbd5ef41..147f40bf934 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -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) + } + ) } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 845c94f01ce..1cc5721268a 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 issue: (args: { repoPath: string; number: number }) => Promise listIssues: (args: { repoPath: string; limit?: number }) => Promise + prChecks: (args: { repoPath: string; prNumber: number }) => Promise + updatePRTitle: (args: { repoPath: string; prNumber: number; title: string }) => Promise } type SettingsApi = { diff --git a/src/preload/index.ts b/src/preload/index.ts index 85f3a7a13ba..0b3a5412a22 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -140,7 +140,16 @@ const api = { ipcRenderer.invoke('gh:issue', args), listIssues: (args: { repoPath: string; limit?: number }): Promise => - ipcRenderer.invoke('gh:listIssues', args) + ipcRenderer.invoke('gh:listIssues', args), + + prChecks: (args: { repoPath: string; prNumber: number }): Promise => + ipcRenderer.invoke('gh:prChecks', args), + + updatePRTitle: (args: { + repoPath: string + prNumber: number + title: string + }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args) }, settings: { diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx new file mode 100644 index 00000000000..0459cb753de --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -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 ( + + + + ) +} + +const CHECK_ICON: Record> = { + success: CircleCheck, + failure: CircleX, + pending: LoaderCircle, + neutral: CircleDashed, + skipped: CircleMinus, + cancelled: CircleX, + timed_out: CircleX +} + +const CHECK_COLOR: Record = { + 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([]) + const [checksLoading, setChecksLoading] = useState(false) + const [editingTitle, setEditingTitle] = useState(false) + const [titleDraft, setTitleDraft] = useState('') + const [titleSaving, setTitleSaving] = useState(false) + const titleInputRef = useRef(null) + const pollRef = useRef | 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 ( +
+ Select a worktree to view PR checks +
+ ) + } + + if (!pr) { + return ( +
+ +
+ No pull request found for this branch +
+
+ Push your branch and open a PR to see checks here +
+
+ ) + } + + // ── 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 ( +
+ {/* PR Header */} +
+ {/* PR number + state badge + refresh + open link */} +
+ + #{pr.number} + + {pr.state} + +
+ + +
+ + {/* PR title (editable) */} + {editingTitle ? ( +
+ setTitleDraft(e.target.value)} + onKeyDown={handleTitleKeyDown} + disabled={titleSaving} + /> + + +
+ ) : ( +
+ {pr.title} + +
+ )} + + {/* Updated at */} + {pr.updatedAt && ( +
+ Updated {new Date(pr.updatedAt).toLocaleString()} +
+ )} +
+ + {/* Checks Summary */} + {checks.length > 0 && ( +
+ {passingCount > 0 && ( + + + {passingCount} passing + + )} + {failingCount > 0 && ( + + + {failingCount} failing + + )} + {pendingCount > 0 && ( + + + {pendingCount} pending + + )} +
+ )} + + {/* Checks List */} + {checksLoading && checks.length === 0 ? ( +
+ +
+ ) : checks.length === 0 ? ( +
+ No checks configured +
+ ) : ( +
+ {sortedChecks.map((check) => { + const conclusion = check.conclusion ?? 'pending' + const Icon = CHECK_ICON[conclusion] ?? CircleDashed + const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' + + return ( +
{ + if (check.url) { + window.api.shell.openUrl(check.url) + } + }} + > + + {check.name} + {check.url && } +
+ ) + })} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index 93ca87793cc..d52fede12d8 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -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 @@ -44,6 +45,12 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [ 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` } ] @@ -110,6 +117,7 @@ export default function RightSidebar(): React.JSX.Element { {rightSidebarTab === 'explorer' && } {rightSidebarTab === 'search' && } {rightSidebarTab === 'source-control' && } + {rightSidebarTab === 'checks' && }
) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 73af42fb6f9..a651734b556 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -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 = { diff --git a/src/shared/types.ts b/src/shared/types.ts index 354cfa47346..f01c74126a0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 From a53903ffb5d91b82498a95cc61fb2e66083236f1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:38:22 -0700 Subject: [PATCH 3/3] 1.0.41 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 12671834450..a7970e30983 100644 --- a/package.json +++ b/package.json @@ -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",