Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
48 lines
3.3 KiB
TypeScript
48 lines
3.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { Term } from './api'
|
||
import { Spinner } from './ui'
|
||
|
||
interface Props {
|
||
terms: Term[]; active: string | null; creating?: boolean
|
||
onSelect: (id: string) => void; onNew: () => void
|
||
onRename: (t: Term) => void; onClose: (t: Term) => void
|
||
onMove: (id: string, before: string | null) => void // put a tab before another, or (null) at the end
|
||
onFocusTerm: () => void
|
||
}
|
||
|
||
// Tab strip across the top of the terminal page: one tab per session, "+" at the end.
|
||
export default function TermTabs({ terms, active, creating, onSelect, onNew, onRename, onClose, onMove, onFocusTerm }: Props) {
|
||
const [dragging, setDragging] = useState<string | null>(null)
|
||
const [over, setOver] = useState<string | null>(null)
|
||
const strip = useRef<HTMLDivElement>(null)
|
||
// keep the active tab in sight when the strip overflows
|
||
useEffect(() => { strip.current?.querySelector('.ttab.active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [active, terms.length])
|
||
return (
|
||
// Buttons must not take focus from the terminal; the tabs themselves must stay draggable, which a
|
||
// prevented mousedown would stop — so a tab click hands focus back afterwards instead.
|
||
<div className="term-tabs" ref={strip} onMouseDown={(e) => { if ((e.target as HTMLElement).closest('button')) e.preventDefault() }}>
|
||
{terms.map((t, i) => {
|
||
const sel = t.id === active
|
||
return (
|
||
<div key={t.id} className={'ttab' + (sel ? ' active' : '') + (t.running ? '' : ' exited') + (over === t.id && dragging !== t.id ? ' drop' : '')}
|
||
draggable onDragStart={(e) => { setDragging(t.id); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', t.id) }}
|
||
onDragOver={(e) => { if (dragging) { e.preventDefault(); setOver(t.id) } }} onDragLeave={() => setOver((o) => (o === t.id ? null : o))}
|
||
onDrop={(e) => { e.preventDefault(); if (dragging && dragging !== t.id) onMove(dragging, t.id); setDragging(null); setOver(null); onFocusTerm() }} onDragEnd={() => { setDragging(null); setOver(null) }}
|
||
onClick={() => { onSelect(t.id); onFocusTerm() }} onDoubleClick={() => onRename(t)}
|
||
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t) } }}>
|
||
<span className={'sdot' + (t.running ? ' run' : '')} />
|
||
<span className="tname" title={t.name}>{t.name}</span>
|
||
{sel && terms.length > 1 && <span className="ttab-move">
|
||
<button className="icon-btn" title="move left" disabled={i === 0} onClick={(e) => { e.stopPropagation(); onMove(t.id, terms[i - 1].id) }}>‹</button>
|
||
<button className="icon-btn" title="move right" disabled={i === terms.length - 1} onClick={(e) => { e.stopPropagation(); onMove(t.id, terms[i + 2]?.id ?? null) }}>›</button>
|
||
</span>}
|
||
{sel && <button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>}
|
||
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
|
||
</div>
|
||
)
|
||
})}
|
||
<button className="ttab new" title="new terminal (Alt+Shift+T) · Alt+Shift+←/→ switches tabs" disabled={creating} onClick={onNew}>{creating ? <Spinner /> : '+'}</button>
|
||
</div>
|
||
)
|
||
}
|