Sidebar updates, expand, GH, landing page

This commit is contained in:
Neil 2026-03-18 00:18:14 -07:00
parent 1a055cd4ca
commit d0ae4ef2a1
12 changed files with 348 additions and 47 deletions

View file

@ -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 />

View file

@ -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 {

View file

@ -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>
)

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 */}

View file

@ -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" />

View file

@ -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">

View file

@ -0,0 +1,5 @@
export const TOGGLE_TERMINAL_PANE_EXPAND_EVENT = 'orca-toggle-terminal-pane-expand'
export type ToggleTerminalPaneExpandDetail = {
tabId: string
}

View 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)
}

View file

@ -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 }
}))
}
})