tab menu, badges

This commit is contained in:
Neil 2026-03-17 13:54:51 -07:00
parent aa2dcdd2ed
commit a7b6df4b0a
14 changed files with 425 additions and 50 deletions

View file

@ -40,6 +40,7 @@ function createWindow(): BrowserWindow {
})
mainWindow.on('ready-to-show', () => {
mainWindow.maximize()
mainWindow.show()
})

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

@ -45,6 +45,8 @@ export interface TerminalTab {
ptyId: string | null
worktreeId: string
title: string
customTitle: string | null
color: string | null
sortOrder: number
createdAt: number
}