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:
OpenSquared
2026-06-30 09:17:05 +02:00
parent 230ce3ff2b
commit 9a1e943be0
4 changed files with 146 additions and 45 deletions

View File

@@ -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"