feat: multi-tab instrument analysis
Each /instruments/:id navigation opens a persistent tab in the TabBar. Multiple instruments can be open simultaneously — state is preserved when switching. - TabsContext: adds instrumentIds[], openInstrument(), closeInstrument() - TabBar: renders instrument tabs (teal, monospaced, TrendingUp icon) after static tabs, separated by a divider - InstrumentDashboard: accepts instrumentIdProp so keep-alive instances use the right id regardless of URL - App: InstrumentRoute registers the tab on navigation; InstrumentKeepAlive mounts one dashboard per open instrument; NormalRoutes hides on instrument paths (hidden div, Routes still fires) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,27 @@
|
||||
import clsx from 'clsx'
|
||||
import { X, ExternalLink } from 'lucide-react'
|
||||
import { X, ExternalLink, TrendingUp } from 'lucide-react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useTabs } from '../../context/TabsContext'
|
||||
import { KEEP_ALIVE_META } from '../../config/keepAliveMeta'
|
||||
|
||||
export default function TabBar() {
|
||||
const { kept, forgetRoute } = useTabs()
|
||||
const { kept, forgetRoute, instrumentIds, closeInstrument } = useTabs()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const visible = KEEP_ALIVE_META.filter(m => kept.has(m.path))
|
||||
if (visible.length === 0) return null
|
||||
const staticVisible = KEEP_ALIVE_META.filter(m => kept.has(m.path))
|
||||
const hasAny = staticVisible.length > 0 || instrumentIds.length > 0
|
||||
if (!hasAny) return null
|
||||
|
||||
const activePath = location.pathname
|
||||
|
||||
return (
|
||||
<div className="flex items-end bg-dark-900 border-b border-slate-700/40 overflow-x-auto shrink-0 scrollbar-none">
|
||||
<div className="flex items-end gap-0.5 px-2 pt-1 min-w-max">
|
||||
{visible.map(({ path, label, icon: Icon }) => {
|
||||
const isActive = location.pathname === path || location.pathname.startsWith(path + '/')
|
||||
|
||||
{/* Static keep-alive tabs */}
|
||||
{staticVisible.map(({ path, label, icon: Icon }) => {
|
||||
const isActive = activePath === path || activePath.startsWith(path + '/')
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
@@ -31,27 +36,72 @@ export default function TabBar() {
|
||||
<Icon size={11} className="shrink-0" />
|
||||
<span className="max-w-24 truncate">{label}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
role="button" tabIndex={-1}
|
||||
title="Ouvrir dans une nouvelle fenêtre"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
window.open(path, '_blank')
|
||||
}}
|
||||
onClick={e => { e.stopPropagation(); window.open(path, '_blank') }}
|
||||
className="ml-1 opacity-0 group-hover:opacity-100 text-slate-600 hover:text-slate-300 rounded hover:bg-slate-600/40 p-0.5 transition-opacity leading-none"
|
||||
>
|
||||
<ExternalLink size={9} />
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
role="button" tabIndex={-1}
|
||||
title="Fermer"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
const remaining = visible.filter(m => m.path !== path)
|
||||
const remaining = staticVisible.filter(m => m.path !== path)
|
||||
forgetRoute(path)
|
||||
if (isActive && remaining.length > 0) {
|
||||
navigate(remaining[remaining.length - 1].path)
|
||||
if (isActive && remaining.length > 0) navigate(remaining[remaining.length - 1].path)
|
||||
else if (isActive && instrumentIds.length > 0) navigate(`/instruments/${instrumentIds[instrumentIds.length - 1]}`)
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-600 hover:text-white rounded hover:bg-slate-600/40 p-0.5 transition-opacity leading-none"
|
||||
>
|
||||
<X size={9} />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Separator between static and instrument tabs */}
|
||||
{staticVisible.length > 0 && instrumentIds.length > 0 && (
|
||||
<div className="w-px h-5 bg-slate-700/50 self-center mx-1 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Dynamic instrument tabs */}
|
||||
{instrumentIds.map(id => {
|
||||
const path = `/instruments/${id}`
|
||||
const isActive = activePath.toLowerCase() === path.toLowerCase()
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => navigate(path)}
|
||||
className={clsx(
|
||||
'group flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-t border border-b-0 transition-all -mb-px select-none shrink-0',
|
||||
isActive
|
||||
? 'bg-dark-800 border-emerald-700/50 text-emerald-300 font-medium z-10'
|
||||
: 'bg-transparent border-transparent text-slate-500 hover:text-emerald-400 hover:bg-dark-800/40 hover:border-slate-700/40'
|
||||
)}
|
||||
>
|
||||
<TrendingUp size={11} className="shrink-0" />
|
||||
<span className="font-mono">{id}</span>
|
||||
<span
|
||||
role="button" tabIndex={-1}
|
||||
title="Ouvrir dans une nouvelle fenêtre"
|
||||
onClick={e => { e.stopPropagation(); window.open(path, '_blank') }}
|
||||
className="ml-0.5 opacity-0 group-hover:opacity-100 text-slate-600 hover:text-slate-300 rounded hover:bg-slate-600/40 p-0.5 transition-opacity leading-none"
|
||||
>
|
||||
<ExternalLink size={9} />
|
||||
</span>
|
||||
<span
|
||||
role="button" tabIndex={-1}
|
||||
title="Fermer"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
const remaining = instrumentIds.filter(x => x !== id)
|
||||
closeInstrument(id)
|
||||
if (isActive) {
|
||||
if (remaining.length > 0) navigate(`/instruments/${remaining[remaining.length - 1]}`)
|
||||
else if (staticVisible.length > 0) navigate(staticVisible[staticVisible.length - 1].path)
|
||||
else navigate('/')
|
||||
}
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-600 hover:text-white rounded hover:bg-slate-600/40 p-0.5 transition-opacity leading-none"
|
||||
|
||||
Reference in New Issue
Block a user