mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
tab menu, badges
This commit is contained in:
parent
aa2dcdd2ed
commit
a7b6df4b0a
14 changed files with 425 additions and 50 deletions
|
|
@ -40,6 +40,7 @@ function createWindow(): BrowserWindow {
|
|||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.maximize()
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ function Settings(): React.JSX.Element {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||
<div className="flex-1 flex min-h-0 flex-col overflow-hidden bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => setActiveView('terminal')}>
|
||||
|
|
@ -101,7 +101,7 @@ function Settings(): React.JSX.Element {
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="max-w-2xl px-8 py-6 space-y-8">
|
||||
{/* ── Workspace ────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -14,17 +14,57 @@ import {
|
|||
arrayMove
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
import { X, Plus, Terminal as TerminalIcon } from 'lucide-react'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import type { TerminalTab } from '../../../shared/types'
|
||||
|
||||
interface SortableTabProps {
|
||||
tab: TerminalTab
|
||||
tabCount: number
|
||||
hasTabsToRight: boolean
|
||||
isActive: 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
|
||||
}
|
||||
|
||||
function SortableTab({ tab, isActive, onActivate, onClose }: SortableTabProps): React.JSX.Element {
|
||||
const TAB_COLORS = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Red', value: '#ef4444' },
|
||||
{ label: 'Orange', value: '#f97316' },
|
||||
{ label: 'Yellow', value: '#eab308' },
|
||||
{ label: 'Green', value: '#22c55e' },
|
||||
{ label: 'Blue', value: '#3b82f6' },
|
||||
{ label: 'Purple', value: '#a855f7' },
|
||||
{ label: 'Pink', value: '#ec4899' }
|
||||
]
|
||||
|
||||
function SortableTab({
|
||||
tab,
|
||||
tabCount,
|
||||
hasTabsToRight,
|
||||
isActive,
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseToRight,
|
||||
onSetCustomTitle,
|
||||
onSetTabColor
|
||||
}: SortableTabProps): React.JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: tab.id
|
||||
})
|
||||
|
|
@ -37,39 +77,99 @@ function SortableTab({ tab, isActive, onActivate, onClose }: SortableTabProps):
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
|
||||
isActive
|
||||
? 'bg-background text-foreground border-b-transparent'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
onPointerDown={(e) => {
|
||||
// Allow dnd-kit to handle drag, but also activate tab
|
||||
onActivate(tab.id)
|
||||
// Forward to dnd-kit listeners
|
||||
listeners?.onPointerDown?.(e)
|
||||
}}
|
||||
>
|
||||
<span className="truncate max-w-[140px] mr-1.5">{tab.title}</span>
|
||||
<button
|
||||
className={`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()
|
||||
onClose(tab.id)
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
|
||||
isActive
|
||||
? 'bg-background text-foreground border-b-transparent'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
onPointerDown={(e) => {
|
||||
onActivate(tab.id)
|
||||
listeners?.onPointerDown?.(e)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose(tab.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TerminalIcon className="w-3.5 h-3.5 mr-1.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate max-w-[130px] mr-1.5">{tab.customTitle ?? tab.title}</span>
|
||||
<button
|
||||
className={`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()
|
||||
onClose(tab.id)
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
{tab.color && (
|
||||
<span
|
||||
className="ml-1.5 size-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tab.color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuItem onClick={() => onClose(tab.id)}>Close</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCloseOthers(tab.id)} disabled={tabCount <= 1}>
|
||||
Close Others
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCloseToRight(tab.id)} disabled={!hasTabsToRight}>
|
||||
Close Tabs To The Right
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const next = window.prompt('Change tab title', tab.customTitle ?? tab.title)
|
||||
if (next === null) return
|
||||
const trimmed = next.trim()
|
||||
onSetCustomTitle(tab.id, trimmed.length > 0 ? trimmed : null)
|
||||
}}
|
||||
>
|
||||
Change Title
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Assign Tab Color</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-44">
|
||||
<ContextMenuRadioGroup
|
||||
value={tab.color ?? 'none'}
|
||||
onValueChange={(value) => onSetTabColor(tab.id, value === 'none' ? null : value)}
|
||||
>
|
||||
{TAB_COLORS.map((color) => (
|
||||
<ContextMenuRadioItem key={color.value} value={color.value} className="gap-2">
|
||||
{color.value !== 'none' ? (
|
||||
<span
|
||||
className="inline-block size-2 rounded-full"
|
||||
style={{ backgroundColor: color.value }}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-block size-2 rounded-full bg-transparent border border-muted-foreground/40" />
|
||||
)}
|
||||
{color.label}
|
||||
</ContextMenuRadioItem>
|
||||
))}
|
||||
</ContextMenuRadioGroup>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -79,8 +179,12 @@ interface TabBarProps {
|
|||
worktreeId: string
|
||||
onActivate: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onCloseOthers: (tabId: string) => void
|
||||
onCloseToRight: (tabId: string) => void
|
||||
onReorder: (worktreeId: string, tabIds: string[]) => void
|
||||
onNewTab: () => void
|
||||
onSetCustomTitle: (tabId: string, title: string | null) => void
|
||||
onSetTabColor: (tabId: string, color: string | null) => void
|
||||
}
|
||||
|
||||
export default function TabBar({
|
||||
|
|
@ -89,8 +193,12 @@ export default function TabBar({
|
|||
worktreeId,
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseToRight,
|
||||
onReorder,
|
||||
onNewTab
|
||||
onNewTab,
|
||||
onSetCustomTitle,
|
||||
onSetTabColor
|
||||
}: TabBarProps): React.JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
|
@ -120,13 +228,19 @@ export default function TabBar({
|
|||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex items-stretch overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
{tabs.map((tab, index) => (
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
tabCount={tabs.length}
|
||||
hasTabsToRight={index < tabs.length - 1}
|
||||
isActive={tab.id === activeTabId}
|
||||
onActivate={onActivate}
|
||||
onClose={onClose}
|
||||
onCloseOthers={onCloseOthers}
|
||||
onCloseToRight={onCloseToRight}
|
||||
onSetCustomTitle={onSetCustomTitle}
|
||||
onSetTabColor={onSetTabColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ export default function Terminal(): React.JSX.Element | null {
|
|||
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)
|
||||
|
||||
if (!activeWorktreeId) return null
|
||||
|
||||
const tabs = tabsByWorktree[activeWorktreeId] ?? []
|
||||
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
|
||||
const prevTabCountRef = useRef(tabs.length)
|
||||
const tabBarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
|
|
@ -31,6 +31,7 @@ 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)
|
||||
}
|
||||
|
|
@ -58,11 +59,13 @@ export default function Terminal(): React.JSX.Element | null {
|
|||
}, [tabs.length])
|
||||
|
||||
const handleNewTab = useCallback(() => {
|
||||
if (!activeWorktreeId) return
|
||||
createTab(activeWorktreeId)
|
||||
}, [activeWorktreeId, createTab])
|
||||
|
||||
const handleCloseTab = useCallback(
|
||||
(tabId: string) => {
|
||||
if (!activeWorktreeId) return
|
||||
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
|
||||
if (currentTabs.length <= 1) {
|
||||
// Last tab - deactivate worktree
|
||||
|
|
@ -89,8 +92,38 @@ export default function Terminal(): React.JSX.Element | null {
|
|||
[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]
|
||||
)
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!activeWorktreeId) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
// Cmd+T - new tab
|
||||
if (e.metaKey && e.key === 't' && !e.shiftKey && !e.repeat) {
|
||||
|
|
@ -127,6 +160,8 @@ export default function Terminal(): React.JSX.Element | null {
|
|||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [activeWorktreeId, handleNewTab, handleCloseTab, setActiveTab])
|
||||
|
||||
if (!activeWorktreeId) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{/* Animated tab bar container using CSS grid for smooth height animation */}
|
||||
|
|
@ -142,8 +177,12 @@ export default function Terminal(): React.JSX.Element | null {
|
|||
worktreeId={activeWorktreeId}
|
||||
onActivate={setActiveTab}
|
||||
onClose={handleCloseTab}
|
||||
onCloseOthers={handleCloseOthers}
|
||||
onCloseToRight={handleCloseTabsToRight}
|
||||
onReorder={reorderTabs}
|
||||
onNewTab={handleNewTab}
|
||||
onSetCustomTitle={setTabCustomTitle}
|
||||
onSetTabColor={setTabColor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -276,12 +276,14 @@ export default function TerminalPane({
|
|||
|
||||
// ResizeObserver to keep terminal sized to container
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const restty = resttyRef.current
|
||||
if (!restty || !isActive) return
|
||||
if (!restty) return
|
||||
const panes = restty.getPanes()
|
||||
for (const p of panes) {
|
||||
p.app.updateSize(true)
|
||||
|
|
@ -294,8 +296,10 @@ export default function TerminalPane({
|
|||
// Terminal pane shortcuts handled at window capture phase so they remain
|
||||
// reliable even when focus is inside the canvas/IME internals.
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (!isActive || e.repeat) return
|
||||
if (e.repeat) return
|
||||
if (!e.metaKey || e.altKey || e.ctrlKey) return
|
||||
|
||||
const restty = resttyRef.current
|
||||
|
|
|
|||
23
src/renderer/src/components/repo/RepoDotLabel.tsx
Normal file
23
src/renderer/src/components/repo/RepoDotLabel.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { CSSProperties } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RepoDotLabelProps {
|
||||
name: string
|
||||
color: string
|
||||
className?: string
|
||||
dotClassName?: string
|
||||
}
|
||||
|
||||
function RepoDotLabel({ name, color, className, dotClassName }: RepoDotLabelProps) {
|
||||
return (
|
||||
<span className={cn('inline-flex min-w-0 items-center gap-1.5', className)}>
|
||||
<span
|
||||
className={cn('size-1.5 shrink-0 rounded-full', dotClassName)}
|
||||
style={{ backgroundColor: color } as CSSProperties}
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default RepoDotLabel
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
SelectContent,
|
||||
SelectItem
|
||||
} from '@/components/ui/select'
|
||||
import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
||||
|
||||
const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
|
|
@ -32,6 +33,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const isOpen = activeModal === 'create-worktree'
|
||||
const selectedRepo = repos.find((r) => r.id === repoId)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
|
|
@ -93,12 +95,20 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
<label className="text-[11px] font-medium text-muted-foreground">Repository</label>
|
||||
<Select value={repoId} onValueChange={setRepoId}>
|
||||
<SelectTrigger className="h-8 text-xs w-full">
|
||||
<SelectValue placeholder="Select repo..." />
|
||||
<SelectValue placeholder="Select repo...">
|
||||
{selectedRepo ? (
|
||||
<RepoDotLabel
|
||||
name={selectedRepo.displayName}
|
||||
color={selectedRepo.badgeColor}
|
||||
dotClassName="size-1.5"
|
||||
/>
|
||||
) : null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repos.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.displayName}
|
||||
<RepoDotLabel name={r.displayName} color={r.badgeColor} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '@/components/ui/select'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
||||
|
||||
const SearchBar = React.memo(function SearchBar() {
|
||||
const searchQuery = useAppStore((s) => s.searchQuery)
|
||||
|
|
@ -21,6 +22,7 @@ const SearchBar = React.memo(function SearchBar() {
|
|||
const filterRepoId = useAppStore((s) => s.filterRepoId)
|
||||
const setFilterRepoId = useAppStore((s) => s.setFilterRepoId)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const selectedRepo = repos.find((r) => r.id === filterRepoId)
|
||||
|
||||
const handleClear = useCallback(() => setSearchQuery(''), [setSearchQuery])
|
||||
const handleToggleActive = useCallback(
|
||||
|
|
@ -68,13 +70,23 @@ const SearchBar = React.memo(function SearchBar() {
|
|||
size="sm"
|
||||
className="h-5 w-auto gap-1 border-none bg-transparent px-1 text-[10px] shadow-none focus-visible:ring-0"
|
||||
>
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{selectedRepo ? (
|
||||
<RepoDotLabel
|
||||
name={selectedRepo.displayName}
|
||||
color={selectedRepo.badgeColor}
|
||||
dotClassName="size-1"
|
||||
/>
|
||||
) : (
|
||||
'All repos'
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectItem value="__all__">All repos</SelectItem>
|
||||
{repos.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.displayName}
|
||||
<RepoDotLabel name={r.displayName} color={r.badgeColor} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useCallback } from 'react'
|
|||
import { useAppStore } from '@/store'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
|
||||
import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
||||
import StatusIndicator from './StatusIndicator'
|
||||
import WorktreeContextMenu from './WorktreeContextMenu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -160,11 +161,16 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
<div className="flex items-center gap-1 min-w-0">
|
||||
{repo && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
variant="dot"
|
||||
className="h-4 px-1.5 text-[9px] font-medium rounded-sm shrink-0"
|
||||
style={badgeStyle}
|
||||
>
|
||||
{repo.displayName}
|
||||
<RepoDotLabel
|
||||
name={repo.displayName}
|
||||
color={repo.badgeColor}
|
||||
className="max-w-[9rem]"
|
||||
dotClassName="size-1"
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground truncate font-mono">{branch}</span>
|
||||
|
|
|
|||
137
src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
Normal file
137
src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
const modalData = useAppStore((s) => s.modalData)
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
|
||||
|
||||
const isLinkIssue = activeModal === 'link-issue'
|
||||
const isEditComment = activeModal === 'edit-comment'
|
||||
const isOpen = isLinkIssue || isEditComment
|
||||
|
||||
const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : ''
|
||||
const currentIssue =
|
||||
typeof modalData.currentIssue === 'number' ? String(modalData.currentIssue) : ''
|
||||
const currentComment =
|
||||
typeof modalData.currentComment === 'string' ? modalData.currentComment : ''
|
||||
|
||||
const [issueInput, setIssueInput] = useState('')
|
||||
const [commentInput, setCommentInput] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setIssueInput(currentIssue)
|
||||
setCommentInput(currentComment)
|
||||
}, [isOpen, currentIssue, currentComment])
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
if (!worktreeId) return false
|
||||
if (isLinkIssue) return issueInput.trim() === '' || !isNaN(parseInt(issueInput.trim(), 10))
|
||||
return true
|
||||
}, [worktreeId, isLinkIssue, issueInput])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) closeModal()
|
||||
},
|
||||
[closeModal]
|
||||
)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!worktreeId) return
|
||||
setSaving(true)
|
||||
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 })
|
||||
}
|
||||
} else if (isEditComment) {
|
||||
await updateWorktreeMeta(worktreeId, { comment: commentInput.trim() })
|
||||
}
|
||||
closeModal()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [
|
||||
worktreeId,
|
||||
isLinkIssue,
|
||||
isEditComment,
|
||||
issueInput,
|
||||
commentInput,
|
||||
updateWorktreeMeta,
|
||||
closeModal
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{isLinkIssue ? 'Link GH Issue' : 'Edit Comment'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{isLinkIssue
|
||||
? 'Add an issue number 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>
|
||||
<Input
|
||||
value={issueInput}
|
||||
onChange={(e) => setIssueInput(e.target.value)}
|
||||
placeholder="e.g. 42"
|
||||
className="h-8 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">Comment</label>
|
||||
<textarea
|
||||
value={commentInput}
|
||||
onChange={(e) => setCommentInput(e.target.value)}
|
||||
placeholder="Notes about this worktree..."
|
||||
rows={3}
|
||||
autoFocus
|
||||
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
className="text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!canSave || saving} className="text-xs">
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})
|
||||
|
||||
export default WorktreeMetaDialog
|
||||
|
|
@ -7,6 +7,7 @@ import GroupControls from './GroupControls'
|
|||
import WorktreeList from './WorktreeList'
|
||||
import SidebarToolbar from './SidebarToolbar'
|
||||
import AddWorktreeDialog from './AddWorktreeDialog'
|
||||
import WorktreeMetaDialog from './WorktreeMetaDialog'
|
||||
|
||||
const MIN_WIDTH = 220
|
||||
const MAX_WIDTH = 500
|
||||
|
|
@ -97,6 +98,7 @@ export default function Sidebar(): React.JSX.Element {
|
|||
|
||||
{/* Dialog (rendered outside sidebar to avoid clipping) */}
|
||||
<AddWorktreeDialog />
|
||||
<WorktreeMetaDialog />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const badgeVariants = cva(
|
|||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
dot: 'bg-muted/50 text-foreground border-border/50',
|
||||
destructive:
|
||||
'bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90',
|
||||
outline:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export interface TerminalSlice {
|
|||
reorderTabs: (worktreeId: string, tabIds: string[]) => void
|
||||
setActiveTab: (tabId: string) => void
|
||||
updateTabTitle: (tabId: string, title: string) => void
|
||||
setTabCustomTitle: (tabId: string, title: string | null) => void
|
||||
setTabColor: (tabId: string, color: string | null) => void
|
||||
updateTabPtyId: (tabId: string, ptyId: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -27,6 +29,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
ptyId: null,
|
||||
worktreeId,
|
||||
title: `Terminal ${existing.length + 1}`,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: existing.length,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
|
|
@ -86,6 +90,26 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
})
|
||||
},
|
||||
|
||||
setTabCustomTitle: (tabId, title) => {
|
||||
set((s) => {
|
||||
const next = { ...s.tabsByWorktree }
|
||||
for (const wId of Object.keys(next)) {
|
||||
next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, customTitle: title } : t))
|
||||
}
|
||||
return { tabsByWorktree: next }
|
||||
})
|
||||
},
|
||||
|
||||
setTabColor: (tabId, color) => {
|
||||
set((s) => {
|
||||
const next = { ...s.tabsByWorktree }
|
||||
for (const wId of Object.keys(next)) {
|
||||
next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, color } : t))
|
||||
}
|
||||
return { tabsByWorktree: next }
|
||||
})
|
||||
},
|
||||
|
||||
updateTabPtyId: (tabId, ptyId) => {
|
||||
set((s) => {
|
||||
const next = { ...s.tabsByWorktree }
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export interface TerminalTab {
|
|||
ptyId: string | null
|
||||
worktreeId: string
|
||||
title: string
|
||||
customTitle: string | null
|
||||
color: string | null
|
||||
sortOrder: number
|
||||
createdAt: number
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue