Files
autocoder/ui/src/components/TerminalTabs.tsx
M Zubair 02d0ef9865 fix(ui): address code review feedback
- ChatMessage: use CSS variable syntax for bg-neo-accent and text consistency
- DebugLogViewer: fix info log level to use --color-neo-log-info
- TerminalTabs: use neo-hover-subtle for hover states instead of text color
- globals.css: fix shimmer effect selector to target .neo-progress-fill
- globals.css: fix loading spinner visibility with explicit border color
- globals.css: add will-change for .neo-btn-yolo performance
- App.tsx: group constants after imports
- NewProjectModal: remove redundant styling (neo-card provides these)
- Add tsconfig.tsbuildinfo to .gitignore and remove from tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:44:35 +01:00

247 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'
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 (e.key === 'Enter') {
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-[#2a2a2a] border-b-2 border-black overflow-x-auto">
{/* Terminal tabs */}
{terminals.map((terminal) => (
<div
key={terminal.id}
className={`
group flex items-center gap-1 px-3 py-1 border-2 border-black cursor-pointer
transition-colors duration-100 select-none min-w-0
${
activeTerminalId === terminal.id
? 'bg-neo-progress text-black'
: 'bg-[#3a3a3a] text-white hover:bg-[var(--color-neo-hover-subtle)]'
}
`}
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="bg-neo-card text-neo-text px-1 py-0 text-sm font-mono border-2 border-black w-24 outline-none"
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
onClick={onCreate}
className="flex items-center justify-center w-8 h-8 border-2 border-black bg-[#3a3a3a] text-white hover:bg-[var(--color-neo-hover-subtle)] transition-colors"
title="New terminal"
>
<Plus className="w-4 h-4" />
</button>
{/* Context menu */}
{contextMenu.visible && (
<div
ref={contextMenuRef}
className="fixed z-50 bg-neo-card border-2 border-[var(--color-neo-border)] py-1 min-w-[120px]"
style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-neo-md)' }}
>
<button
onClick={handleContextMenuRename}
className="w-full px-3 py-1 text-left text-sm font-mono hover:bg-neo-progress hover:text-black transition-colors"
>
Rename
</button>
{terminals.length > 1 && (
<button
onClick={handleContextMenuClose}
className="w-full px-3 py-1 text-left text-sm font-mono hover:bg-neo-danger hover:text-white transition-colors"
>
Close
</button>
)}
</div>
)}
</div>
)
}