mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 08:02:37 +00:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user