Files
autocoder/ui/src/components/TerminalTabs.tsx
Auto a12e4aa3b8 refactor(ui): extract keyboard utilities and add padding constant
- Create shared `isSubmitEnter()` utility in `ui/src/lib/keyboard.ts`
  for IME-aware Enter key handling across all input components
- Extract magic number 48 to named constant `COLLAPSED_DEBUG_PANEL_CLEARANCE`
  with explanatory comment (40px panel header + 8px margin)
- Update 5 components to use the new utility:
  - AssistantChat.tsx
  - ExpandProjectChat.tsx
  - SpecCreationChat.tsx
  - FolderBrowser.tsx
  - TerminalTabs.tsx

This follows up on PR #121 which added IME composition checks. The
refactoring centralizes the logic for easier maintenance and documents
the padding value that prevents Kanban cards from being cut off when
the debug panel is collapsed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:28:48 +02:00

252 lines
7.1 KiB
TypeScript

/**
* Terminal Tabs Component
*
* Manages multiple terminal tabs with add, rename, and close functionality.
* Supports inline rename via double-click and context menu.
*/
import { useState, useRef, useEffect, useCallback } from 'react'
import { Plus, X } from 'lucide-react'
import type { TerminalInfo } from '@/lib/types'
import { isSubmitEnter } from '@/lib/keyboard'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
interface TerminalTabsProps {
terminals: TerminalInfo[]
activeTerminalId: string | null
onSelect: (terminalId: string) => void
onCreate: () => void
onRename: (terminalId: string, newName: string) => void
onClose: (terminalId: string) => void
}
interface ContextMenuState {
visible: boolean
x: number
y: number
terminalId: string | null
}
export function TerminalTabs({
terminals,
activeTerminalId,
onSelect,
onCreate,
onRename,
onClose,
}: TerminalTabsProps) {
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
visible: false,
x: 0,
y: 0,
terminalId: null,
})
const inputRef = useRef<HTMLInputElement>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
// Focus input when editing starts
useEffect(() => {
if (editingId && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [editingId])
// Close context menu when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(e.target as Node)
) {
setContextMenu((prev) => ({ ...prev, visible: false }))
}
}
if (contextMenu.visible) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [contextMenu.visible])
// Start editing a terminal name
const startEditing = useCallback((terminal: TerminalInfo) => {
setEditingId(terminal.id)
setEditValue(terminal.name)
setContextMenu((prev) => ({ ...prev, visible: false }))
}, [])
// Handle edit submission
const submitEdit = useCallback(() => {
if (editingId && editValue.trim()) {
onRename(editingId, editValue.trim())
}
setEditingId(null)
setEditValue('')
}, [editingId, editValue, onRename])
// Cancel editing
const cancelEdit = useCallback(() => {
setEditingId(null)
setEditValue('')
}, [])
// Handle key events during editing
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isSubmitEnter(e, false)) {
e.preventDefault()
submitEdit()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
}
},
[submitEdit, cancelEdit]
)
// Handle double-click to start editing
const handleDoubleClick = useCallback(
(terminal: TerminalInfo) => {
startEditing(terminal)
},
[startEditing]
)
// Handle context menu
const handleContextMenu = useCallback(
(e: React.MouseEvent, terminalId: string) => {
e.preventDefault()
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
terminalId,
})
},
[]
)
// Handle context menu actions
const handleContextMenuRename = useCallback(() => {
if (contextMenu.terminalId) {
const terminal = terminals.find((t) => t.id === contextMenu.terminalId)
if (terminal) {
startEditing(terminal)
}
}
}, [contextMenu.terminalId, terminals, startEditing])
const handleContextMenuClose = useCallback(() => {
if (contextMenu.terminalId) {
onClose(contextMenu.terminalId)
}
setContextMenu((prev) => ({ ...prev, visible: false }))
}, [contextMenu.terminalId, onClose])
// Handle tab close with confirmation if needed
const handleClose = useCallback(
(e: React.MouseEvent, terminalId: string) => {
e.stopPropagation()
onClose(terminalId)
},
[onClose]
)
return (
<div className="flex items-center gap-1 px-2 py-1 bg-zinc-900 border-b border-border overflow-x-auto">
{/* Terminal tabs */}
{terminals.map((terminal) => (
<div
key={terminal.id}
className={`
group flex items-center gap-1 px-3 py-1 rounded cursor-pointer
transition-colors duration-100 select-none min-w-0
${
activeTerminalId === terminal.id
? 'bg-primary text-primary-foreground'
: 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700'
}
`}
onClick={() => onSelect(terminal.id)}
onDoubleClick={() => handleDoubleClick(terminal)}
onContextMenu={(e) => handleContextMenu(e, terminal.id)}
>
{editingId === terminal.id ? (
<Input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={submitEdit}
onKeyDown={handleKeyDown}
className="h-6 px-1 py-0 text-sm font-mono w-24"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="text-sm font-mono truncate max-w-[120px]">
{terminal.name}
</span>
)}
{/* Close button */}
{terminals.length > 1 && (
<button
onClick={(e) => handleClose(e, terminal.id)}
className={`
p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity
${
activeTerminalId === terminal.id
? 'hover:bg-black/20'
: 'hover:bg-white/20'
}
`}
title="Close terminal"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
{/* Add new terminal button */}
<Button
variant="ghost"
size="icon"
onClick={onCreate}
className="h-8 w-8 bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
title="New terminal"
>
<Plus className="w-4 h-4" />
</Button>
{/* Context menu */}
{contextMenu.visible && (
<div
ref={contextMenuRef}
className="fixed z-50 bg-popover border border-border rounded-md py-1 min-w-[120px] shadow-md"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
onClick={handleContextMenuRename}
className="w-full px-3 py-1.5 text-left text-sm font-mono hover:bg-accent transition-colors"
>
Rename
</button>
{terminals.length > 1 && (
<button
onClick={handleContextMenuClose}
className="w-full px-3 py-1.5 text-left text-sm font-mono text-destructive hover:bg-destructive hover:text-destructive-foreground transition-colors"
>
Close
</button>
)}
</div>
)}
</div>
)
}