mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 22:32:06 +00:00
- 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>
247 lines
7.1 KiB
TypeScript
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>
|
|
)
|
|
}
|