mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
Sidebar updates, expand, GH, landing page
This commit is contained in:
parent
1a055cd4ca
commit
d0ae4ef2a1
12 changed files with 348 additions and 47 deletions
|
|
@ -1,4 +1,6 @@
|
|||
import { useEffect } from 'react'
|
||||
import { Minimize2, PanelLeft } from 'lucide-react'
|
||||
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
|
||||
import { useAppStore } from './store'
|
||||
import { useIpcEvents } from './hooks/useIpcEvents'
|
||||
import Sidebar from './components/Sidebar'
|
||||
|
|
@ -10,6 +12,10 @@ function App(): React.JSX.Element {
|
|||
const toggleSidebar = useAppStore((s) => s.toggleSidebar)
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const activeTabId = useAppStore((s) => s.activeTabId)
|
||||
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
|
||||
const canExpandPaneByTabId = useAppStore((s) => s.canExpandPaneByTabId)
|
||||
const fetchRepos = useAppStore((s) => s.fetchRepos)
|
||||
const fetchSettings = useAppStore((s) => s.fetchSettings)
|
||||
const initGitHubCache = useAppStore((s) => s.initGitHubCache)
|
||||
|
|
@ -50,27 +56,50 @@ function App(): React.JSX.Element {
|
|||
}
|
||||
}, [settings?.theme])
|
||||
|
||||
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
|
||||
const hasTabBar = tabs.length >= 2
|
||||
const effectiveActiveTabId = activeTabId ?? tabs[0]?.id ?? null
|
||||
const activeTabCanExpand = effectiveActiveTabId
|
||||
? (canExpandPaneByTabId[effectiveActiveTabId] ?? false)
|
||||
: false
|
||||
const effectiveActiveTabExpanded = effectiveActiveTabId
|
||||
? (expandedPaneByTabId[effectiveActiveTabId] ?? false)
|
||||
: false
|
||||
const showTitlebarExpandButton =
|
||||
activeView !== 'settings' &&
|
||||
activeWorktreeId !== null &&
|
||||
!hasTabBar &&
|
||||
effectiveActiveTabExpanded
|
||||
|
||||
const handleToggleExpand = (): void => {
|
||||
if (!effectiveActiveTabId) return
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, {
|
||||
detail: { tabId: effectiveActiveTabId }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen overflow-hidden">
|
||||
<div className="titlebar">
|
||||
<div className="titlebar-traffic-light-pad" />
|
||||
<button className="sidebar-toggle" onClick={toggleSidebar} title="Toggle sidebar">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="2" y1="4" x2="14" y2="4" />
|
||||
<line x1="2" y1="8" x2="14" y2="8" />
|
||||
<line x1="2" y1="12" x2="14" y2="12" />
|
||||
</svg>
|
||||
<PanelLeft size={16} />
|
||||
</button>
|
||||
<div className="titlebar-title">Orca</div>
|
||||
<div className="titlebar-spacer" />
|
||||
{showTitlebarExpandButton && (
|
||||
<button
|
||||
className="titlebar-icon-button"
|
||||
onClick={handleToggleExpand}
|
||||
title="Collapse pane"
|
||||
aria-label="Collapse pane"
|
||||
disabled={!activeTabCanExpand}
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
<Sidebar />
|
||||
|
|
|
|||
|
|
@ -176,6 +176,17 @@
|
|||
scrollbar-color: var(--muted-foreground, #737373) transparent;
|
||||
}
|
||||
|
||||
/* Hide tab-strip scrollbars to prevent drag-time scrollbar flashes */
|
||||
.terminal-tab-strip {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.terminal-tab-strip::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────── */
|
||||
|
||||
#root {
|
||||
|
|
@ -247,6 +258,36 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titlebar-icon-button {
|
||||
-webkit-app-region: no-drag;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 150ms;
|
||||
}
|
||||
|
||||
.titlebar-icon-button:hover {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.titlebar-icon-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.titlebar-icon-button:disabled:hover {
|
||||
background: none;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* ── Content ─────────────────────────────────────────── */
|
||||
|
||||
.content-area {
|
||||
|
|
|
|||
|
|
@ -1,26 +1,91 @@
|
|||
import { useMemo } from 'react'
|
||||
import { FolderPlus, GitBranchPlus } from 'lucide-react'
|
||||
import { useAppStore } from '../store'
|
||||
import logo from '../../../../resources/icon.png'
|
||||
|
||||
type ShortcutItem = {
|
||||
id: string
|
||||
keys: string[]
|
||||
action: string
|
||||
}
|
||||
|
||||
function KeyCap({ label }: { label: string }): React.JSX.Element {
|
||||
return (
|
||||
<span className="inline-flex min-w-6 items-center justify-center rounded border border-border/80 bg-secondary/70 px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Landing(): React.JSX.Element {
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const addRepo = useAppStore((s) => s.addRepo)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
|
||||
const canCreateWorktree = repos.length > 0
|
||||
|
||||
const shortcuts = useMemo<ShortcutItem[]>(() => {
|
||||
const items: ShortcutItem[] = []
|
||||
if (canCreateWorktree) {
|
||||
items.push({ id: 'create', keys: ['⌘', 'N'], action: 'Create worktree' })
|
||||
items.push({ id: 'up', keys: ['⌘', '⇧', '↑'], action: 'Move up worktree' })
|
||||
items.push({ id: 'down', keys: ['⌘', '⇧', '↓'], action: 'Move down worktree' })
|
||||
}
|
||||
return items
|
||||
}, [canCreateWorktree])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<h1 className="text-5xl font-bold text-muted-foreground tracking-tight">Orca</h1>
|
||||
{repos.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">Get started by adding a repository</p>
|
||||
<div className="w-full max-w-lg px-6">
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<img
|
||||
src={logo}
|
||||
alt="Orca logo"
|
||||
className="size-16 rounded-xl shadow-lg shadow-black/40"
|
||||
/>
|
||||
<h1 className="text-4xl font-bold text-foreground tracking-tight">ORCA</h1>
|
||||
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{canCreateWorktree
|
||||
? 'Select a worktree from the sidebar to begin.'
|
||||
: 'Add a repository to get started.'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-2.5 flex-wrap">
|
||||
<button
|
||||
className="bg-secondary border border-border text-foreground font-mono text-sm px-6 py-2 rounded-md cursor-pointer hover:bg-accent transition-colors"
|
||||
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={addRepo}
|
||||
>
|
||||
Add Repository
|
||||
<FolderPlus className="size-3.5" />
|
||||
Add Repo
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Select a worktree from the sidebar</p>
|
||||
)}
|
||||
|
||||
{canCreateWorktree && (
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => openModal('create-worktree')}
|
||||
>
|
||||
<GitBranchPlus className="size-3.5" />
|
||||
Create Worktree
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shortcuts.length > 0 && (
|
||||
<div className="mt-6 w-full max-w-xs space-y-2">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<div key={shortcut.id} className="grid grid-cols-[1fr_auto] items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">{shortcut.action}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key) => (
|
||||
<KeyCap key={`${shortcut.id}-${key}`} label={key} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
arrayMove
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { X, Plus, Terminal as TerminalIcon } from 'lucide-react'
|
||||
import { X, Plus, Terminal as TerminalIcon, Minimize2 } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -29,12 +29,14 @@ interface SortableTabProps {
|
|||
tabCount: number
|
||||
hasTabsToRight: boolean
|
||||
isActive: boolean
|
||||
isExpanded: boolean
|
||||
onActivate: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onCloseOthers: (tabId: string) => void
|
||||
onCloseToRight: (tabId: string) => void
|
||||
onSetCustomTitle: (tabId: string, title: string | null) => void
|
||||
onSetTabColor: (tabId: string, color: string | null) => void
|
||||
onToggleExpand: (tabId: string) => void
|
||||
}
|
||||
|
||||
const TAB_COLORS = [
|
||||
|
|
@ -57,12 +59,14 @@ function SortableTab({
|
|||
tabCount,
|
||||
hasTabsToRight,
|
||||
isActive,
|
||||
isExpanded,
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseToRight,
|
||||
onSetCustomTitle,
|
||||
onSetTabColor
|
||||
onSetTabColor,
|
||||
onToggleExpand
|
||||
}: SortableTabProps): React.JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: tab.id
|
||||
|
|
@ -124,6 +128,24 @@ function SortableTab({
|
|||
style={{ backgroundColor: tab.color }}
|
||||
/>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<button
|
||||
className={`mr-1 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
isActive
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
|
||||
}`}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleExpand(tab.id)
|
||||
}}
|
||||
title="Collapse pane"
|
||||
aria-label="Collapse pane"
|
||||
>
|
||||
<Minimize2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
isActive
|
||||
|
|
@ -209,6 +231,7 @@ interface TabBarProps {
|
|||
tabs: TerminalTab[]
|
||||
activeTabId: string | null
|
||||
worktreeId: string
|
||||
expandedPaneByTabId: Record<string, boolean>
|
||||
onActivate: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onCloseOthers: (tabId: string) => void
|
||||
|
|
@ -217,12 +240,14 @@ interface TabBarProps {
|
|||
onNewTab: () => void
|
||||
onSetCustomTitle: (tabId: string, title: string | null) => void
|
||||
onSetTabColor: (tabId: string, color: string | null) => void
|
||||
onTogglePaneExpand: (tabId: string) => void
|
||||
}
|
||||
|
||||
export default function TabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
worktreeId,
|
||||
expandedPaneByTabId,
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
|
|
@ -230,7 +255,8 @@ export default function TabBar({
|
|||
onReorder,
|
||||
onNewTab,
|
||||
onSetCustomTitle,
|
||||
onSetTabColor
|
||||
onSetTabColor,
|
||||
onTogglePaneExpand
|
||||
}: TabBarProps): React.JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
|
@ -259,7 +285,7 @@ export default function TabBar({
|
|||
<div className="flex items-stretch h-9 bg-card border-b border-border overflow-hidden shrink-0">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex items-stretch overflow-x-auto">
|
||||
<div className="terminal-tab-strip flex items-stretch overflow-x-auto overflow-y-hidden">
|
||||
{tabs.map((tab, index) => (
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
|
|
@ -267,12 +293,14 @@ export default function TabBar({
|
|||
tabCount={tabs.length}
|
||||
hasTabsToRight={index < tabs.length - 1}
|
||||
isActive={tab.id === activeTabId}
|
||||
isExpanded={expandedPaneByTabId[tab.id] === true}
|
||||
onActivate={onActivate}
|
||||
onClose={onClose}
|
||||
onCloseOthers={onCloseOthers}
|
||||
onCloseToRight={onCloseToRight}
|
||||
onSetCustomTitle={onSetCustomTitle}
|
||||
onSetTabColor={onSetTabColor}
|
||||
onToggleExpand={onTogglePaneExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useCallback, useRef } from 'react'
|
||||
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
|
||||
import { useAppStore } from '../store'
|
||||
import TabBar from './TabBar'
|
||||
import TerminalPane from './TerminalPane'
|
||||
|
|
@ -15,10 +16,12 @@ export default function Terminal(): React.JSX.Element | null {
|
|||
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
|
||||
const setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle)
|
||||
const setTabColor = useAppStore((s) => s.setTabColor)
|
||||
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
|
||||
|
||||
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
|
||||
const prevTabCountRef = useRef(tabs.length)
|
||||
const tabBarRef = useRef<HTMLDivElement>(null)
|
||||
const initialTabCreationGuardRef = useRef<string | null>(null)
|
||||
|
||||
// Find the active worktree to get its path
|
||||
const activeWorktree = activeWorktreeId
|
||||
|
|
@ -31,10 +34,23 @@ export default function Terminal(): React.JSX.Element | null {
|
|||
|
||||
// Auto-create first tab when worktree activates
|
||||
useEffect(() => {
|
||||
if (!activeWorktreeId) return
|
||||
if (activeWorktreeId && tabs.length === 0) {
|
||||
createTab(activeWorktreeId)
|
||||
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)
|
||||
}, [activeWorktreeId, tabs.length, createTab])
|
||||
|
||||
// Ensure activeTabId is valid
|
||||
|
|
@ -120,6 +136,20 @@ export default function Terminal(): React.JSX.Element | null {
|
|||
[activeWorktreeId, closeTab]
|
||||
)
|
||||
|
||||
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
|
||||
|
|
@ -183,6 +213,8 @@ export default function Terminal(): React.JSX.Element | null {
|
|||
onNewTab={handleNewTab}
|
||||
onSetCustomTitle={setTabCustomTitle}
|
||||
onSetTabColor={setTabColor}
|
||||
expandedPaneByTabId={expandedPaneByTabId}
|
||||
onTogglePaneExpand={handleTogglePaneExpand}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Restty, getBuiltinTheme } from 'restty'
|
||||
import { Clipboard, Copy, Eraser, PanelBottomOpen, PanelRightOpen, X, ZoomIn } from 'lucide-react'
|
||||
import {
|
||||
Clipboard,
|
||||
Copy,
|
||||
Eraser,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
PanelBottomOpen,
|
||||
PanelRightOpen,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -9,6 +18,7 @@ import {
|
|||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
type PtyTransport = {
|
||||
|
|
@ -169,10 +179,13 @@ export default function TerminalPane({
|
|||
const [terminalMenuOpen, setTerminalMenuOpen] = useState(false)
|
||||
const [terminalMenuPoint, setTerminalMenuPoint] = useState({ x: 0, y: 0 })
|
||||
const [expandedPaneId, setExpandedPaneId] = useState<number | null>(null)
|
||||
const setTabPaneExpanded = useAppStore((s) => s.setTabPaneExpanded)
|
||||
const setTabCanExpandPane = useAppStore((s) => s.setTabCanExpandPane)
|
||||
|
||||
const setExpandedPane = (paneId: number | null): void => {
|
||||
expandedPaneIdRef.current = paneId
|
||||
setExpandedPaneId(paneId)
|
||||
setTabPaneExpanded(tabId, paneId !== null)
|
||||
}
|
||||
|
||||
const rememberPaneStyle = (
|
||||
|
|
@ -256,6 +269,11 @@ export default function TerminalPane({
|
|||
applyExpandedLayout(paneId)
|
||||
}
|
||||
|
||||
const syncCanExpandState = (): void => {
|
||||
const paneCount = resttyRef.current?.getPanes().length ?? 1
|
||||
setTabCanExpandPane(tabId, paneCount > 1)
|
||||
}
|
||||
|
||||
const toggleExpandPane = (paneId: number): void => {
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
|
|
@ -374,12 +392,14 @@ export default function TerminalPane({
|
|||
onActivePaneChange: () => {},
|
||||
onLayoutChanged: () => {
|
||||
syncExpandedLayout()
|
||||
syncCanExpandState()
|
||||
queueResizeAll(false)
|
||||
}
|
||||
})
|
||||
|
||||
restty.createInitialPane({ focus: isActive })
|
||||
resttyRef.current = restty
|
||||
syncCanExpandState()
|
||||
queueResizeAll(isActive)
|
||||
|
||||
return () => {
|
||||
|
|
@ -387,6 +407,8 @@ export default function TerminalPane({
|
|||
restoreExpandedLayout()
|
||||
restty.destroy()
|
||||
resttyRef.current = null
|
||||
setTabPaneExpanded(tabId, false)
|
||||
setTabCanExpandPane(tabId, false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tabId, cwd])
|
||||
|
|
@ -412,6 +434,23 @@ export default function TerminalPane({
|
|||
wasActiveRef.current = isActive
|
||||
}, [isActive])
|
||||
|
||||
useEffect(() => {
|
||||
const onToggleExpand = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<{ tabId?: string }>).detail
|
||||
if (!detail?.tabId || detail.tabId !== tabId) return
|
||||
const restty = resttyRef.current
|
||||
if (!restty) return
|
||||
const panes = restty.getPanes()
|
||||
if (panes.length < 2) return
|
||||
const pane = restty.getActivePane() ?? panes[0]
|
||||
if (!pane) return
|
||||
toggleExpandPane(pane.id)
|
||||
}
|
||||
|
||||
window.addEventListener(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, onToggleExpand)
|
||||
return () => window.removeEventListener(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, onToggleExpand)
|
||||
}, [tabId])
|
||||
|
||||
// ResizeObserver to keep terminal sized to container
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
|
@ -616,7 +655,7 @@ export default function TerminalPane({
|
|||
</DropdownMenuItem>
|
||||
{canExpandPane && (
|
||||
<DropdownMenuItem onSelect={handleToggleExpand}>
|
||||
<ZoomIn />
|
||||
{menuPaneIsExpanded ? <Minimize2 /> : <Maximize2 />}
|
||||
{menuPaneIsExpanded ? 'Collapse Pane' : 'Expand Pane'}
|
||||
<DropdownMenuShortcut>⌘⇧↩</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
SelectItem
|
||||
} from '@/components/ui/select'
|
||||
import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
||||
import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
|
||||
|
||||
const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
|
|
@ -56,8 +57,10 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
if (wt) {
|
||||
const metaUpdates: Record<string, unknown> = {}
|
||||
if (linkedIssue.trim()) {
|
||||
const num = parseInt(linkedIssue.trim(), 10)
|
||||
if (!isNaN(num)) (metaUpdates as { linkedIssue: number }).linkedIssue = num
|
||||
const linkedIssueNumber = parseGitHubIssueOrPRNumber(linkedIssue)
|
||||
if (linkedIssueNumber !== null) {
|
||||
;(metaUpdates as { linkedIssue: number }).linkedIssue = linkedIssueNumber
|
||||
}
|
||||
}
|
||||
if (comment.trim()) {
|
||||
;(metaUpdates as { comment: string }).comment = comment.trim()
|
||||
|
|
@ -130,14 +133,17 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
{/* Link GH Issue */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">
|
||||
Link GH Issue <span className="text-muted-foreground/50">(optional)</span>
|
||||
Link GH Issue/PR <span className="text-muted-foreground/50">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
value={linkedIssue}
|
||||
onChange={(e) => setLinkedIssue(e.target.value)}
|
||||
placeholder="Issue number, e.g. 42"
|
||||
placeholder="Issue/PR # or GitHub URL"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Paste an issue or PR URL, or enter a number.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleLinkIssue}>
|
||||
<Link className="size-3.5" />
|
||||
{worktree.linkedIssue ? 'Edit GH Issue' : 'Link GH Issue'}
|
||||
{worktree.linkedIssue ? 'Edit GH Issue/PR' : 'Link GH Issue/PR'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleComment}>
|
||||
<MessageSquare className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
|
||||
|
||||
const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
|
|
@ -39,7 +40,8 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
|
|||
|
||||
const canSave = useMemo(() => {
|
||||
if (!worktreeId) return false
|
||||
if (isLinkIssue) return issueInput.trim() === '' || !isNaN(parseInt(issueInput.trim(), 10))
|
||||
if (isLinkIssue)
|
||||
return issueInput.trim() === '' || parseGitHubIssueOrPRNumber(issueInput) !== null
|
||||
return true
|
||||
}, [worktreeId, isLinkIssue, issueInput])
|
||||
|
||||
|
|
@ -56,9 +58,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
|
|||
try {
|
||||
if (isLinkIssue) {
|
||||
const trimmed = issueInput.trim()
|
||||
const linkedIssue = trimmed === '' ? null : parseInt(trimmed, 10)
|
||||
if (!isNaN(linkedIssue as number) || trimmed === '') {
|
||||
await updateWorktreeMeta(worktreeId, { linkedIssue: linkedIssue as number | null })
|
||||
const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmed)
|
||||
if (trimmed === '' || linkedIssueNumber !== null) {
|
||||
await updateWorktreeMeta(worktreeId, { linkedIssue: linkedIssueNumber })
|
||||
}
|
||||
} else if (isEditComment) {
|
||||
await updateWorktreeMeta(worktreeId, { comment: commentInput.trim() })
|
||||
|
|
@ -82,25 +84,28 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
|
|||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{isLinkIssue ? 'Link GH Issue' : 'Edit Comment'}
|
||||
{isLinkIssue ? 'Link GH Issue/PR' : 'Edit Comment'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{isLinkIssue
|
||||
? 'Add an issue number to link this worktree. Leave blank to remove the link.'
|
||||
? 'Add an issue/PR number or URL to link this worktree. Leave blank to remove the link.'
|
||||
: 'Add or edit notes for this worktree.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLinkIssue ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">Issue Number</label>
|
||||
<label className="text-[11px] font-medium text-muted-foreground">GH Issue / PR</label>
|
||||
<Input
|
||||
value={issueInput}
|
||||
onChange={(e) => setIssueInput(e.target.value)}
|
||||
placeholder="e.g. 42"
|
||||
placeholder="Issue/PR # or GitHub URL"
|
||||
className="h-8 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Paste an issue or PR URL, or enter a number.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
|
|
|
|||
5
src/renderer/src/constants/terminal.ts
Normal file
5
src/renderer/src/constants/terminal.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const TOGGLE_TERMINAL_PANE_EXPAND_EVENT = 'orca-toggle-terminal-pane-expand'
|
||||
|
||||
export type ToggleTerminalPaneExpandDetail = {
|
||||
tabId: string
|
||||
}
|
||||
27
src/renderer/src/lib/github-links.ts
Normal file
27
src/renderer/src/lib/github-links.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
const GH_ITEM_PATH_RE = /^\/[^/]+\/[^/]+\/(?:issues|pull)\/(\d+)(?:\/)?$/i
|
||||
|
||||
/**
|
||||
* Parses a GitHub issue/PR reference from plain input.
|
||||
* Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs.
|
||||
*/
|
||||
export function parseGitHubIssueOrPRNumber(input: string): number | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
|
||||
if (/^\d+$/.test(numeric)) return Number.parseInt(numeric, 10)
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!/^(?:www\.)?github\.com$/i.test(url.hostname)) return null
|
||||
|
||||
const match = GH_ITEM_PATH_RE.exec(url.pathname)
|
||||
if (!match) return null
|
||||
|
||||
return Number.parseInt(match[1], 10)
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import type { TerminalTab } from '../../../../shared/types'
|
|||
export interface TerminalSlice {
|
||||
tabsByWorktree: Record<string, TerminalTab[]>
|
||||
activeTabId: string | null
|
||||
expandedPaneByTabId: Record<string, boolean>
|
||||
canExpandPaneByTabId: Record<string, boolean>
|
||||
createTab: (worktreeId: string) => TerminalTab
|
||||
closeTab: (tabId: string) => void
|
||||
reorderTabs: (worktreeId: string, tabIds: string[]) => void
|
||||
|
|
@ -13,11 +15,15 @@ export interface TerminalSlice {
|
|||
setTabCustomTitle: (tabId: string, title: string | null) => void
|
||||
setTabColor: (tabId: string, color: string | null) => void
|
||||
updateTabPtyId: (tabId: string, ptyId: string) => void
|
||||
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
|
||||
setTabCanExpandPane: (tabId: string, canExpand: boolean) => void
|
||||
}
|
||||
|
||||
export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice> = (set) => ({
|
||||
tabsByWorktree: {},
|
||||
activeTabId: null,
|
||||
expandedPaneByTabId: {},
|
||||
canExpandPaneByTabId: {},
|
||||
|
||||
createTab: (worktreeId) => {
|
||||
const id = globalThis.crypto.randomUUID()
|
||||
|
|
@ -55,9 +61,15 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
next[wId] = after
|
||||
}
|
||||
}
|
||||
const nextExpanded = { ...s.expandedPaneByTabId }
|
||||
delete nextExpanded[tabId]
|
||||
const nextCanExpand = { ...s.canExpandPaneByTabId }
|
||||
delete nextCanExpand[tabId]
|
||||
return {
|
||||
tabsByWorktree: next,
|
||||
activeTabId: s.activeTabId === tabId ? null : s.activeTabId
|
||||
activeTabId: s.activeTabId === tabId ? null : s.activeTabId,
|
||||
expandedPaneByTabId: nextExpanded,
|
||||
canExpandPaneByTabId: nextCanExpand
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
@ -118,5 +130,17 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
}
|
||||
return { tabsByWorktree: next }
|
||||
})
|
||||
},
|
||||
|
||||
setTabPaneExpanded: (tabId, expanded) => {
|
||||
set((s) => ({
|
||||
expandedPaneByTabId: { ...s.expandedPaneByTabId, [tabId]: expanded }
|
||||
}))
|
||||
},
|
||||
|
||||
setTabCanExpandPane: (tabId, canExpand) => {
|
||||
set((s) => ({
|
||||
canExpandPaneByTabId: { ...s.canExpandPaneByTabId, [tabId]: canExpand }
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue