1- import { useRef } from 'react'
1+ import { useRef , useState , useEffect , useCallback } from 'react'
22import { X } from 'lucide-react'
33
44export interface TabEntry {
@@ -11,112 +11,200 @@ interface FileTabsProps {
1111 dirtyPaths : Set < string >
1212 onSwitch : ( path : string ) => void
1313 onClose : ( path : string ) => void
14+ onCloseAll ?: ( ) => void
15+ onCloseOthers ?: ( path : string ) => void
16+ onCloseToLeft ?: ( path : string ) => void
17+ onCloseToRight ?: ( path : string ) => void
1418}
1519
1620function fileName ( path : string ) : string {
1721 return path . split ( '/' ) . pop ( ) ?? path
1822}
1923
20- export default function FileTabs ( { tabs, activePath, dirtyPaths, onSwitch, onClose } : FileTabsProps ) {
24+ interface ContextMenu {
25+ x : number
26+ y : number
27+ tabPath : string
28+ tabIndex : number
29+ }
30+
31+ export default function FileTabs ( { tabs, activePath, dirtyPaths, onSwitch, onClose, onCloseAll, onCloseOthers, onCloseToLeft, onCloseToRight } : FileTabsProps ) {
2132 const scrollRef = useRef < HTMLDivElement > ( null )
33+ const [ ctxMenu , setCtxMenu ] = useState < ContextMenu | null > ( null )
2234
23- if ( tabs . length === 0 ) return null
35+ // Close context menu on click outside or Escape
36+ useEffect ( ( ) => {
37+ if ( ! ctxMenu ) return
38+ const handleClose = ( ) => setCtxMenu ( null )
39+ const handleKey = ( e : KeyboardEvent ) => { if ( e . key === 'Escape' ) setCtxMenu ( null ) }
40+ document . addEventListener ( 'click' , handleClose )
41+ document . addEventListener ( 'keydown' , handleKey )
42+ return ( ) => {
43+ document . removeEventListener ( 'click' , handleClose )
44+ document . removeEventListener ( 'keydown' , handleKey )
45+ }
46+ } , [ ctxMenu ] )
2447
25- return (
26- < div
27- ref = { scrollRef }
28- className = "flex items-end overflow-x-auto flex-shrink-0"
29- style = { {
30- background : 'var(--bg-card)' ,
31- borderBottom : '1px solid var(--border)' ,
32- scrollbarWidth : 'none' ,
33- msOverflowStyle : 'none' ,
34- minHeight : '36px' ,
35- } }
36- >
37- < style > { `
38- .file-tabs-scroll::-webkit-scrollbar { display: none; }
39- .file-tab-close { opacity: 0; }
40- .file-tab:hover .file-tab-close,
41- .file-tab.active .file-tab-close { opacity: 1; }
42- ` } </ style >
43- { tabs . map ( tab => {
44- const isActive = tab . path === activePath
45- const isDirty = dirtyPaths . has ( tab . path )
48+ const handleContextMenu = useCallback ( ( e : React . MouseEvent , tabPath : string , tabIndex : number ) => {
49+ e . preventDefault ( )
50+ setCtxMenu ( { x : e . clientX , y : e . clientY , tabPath, tabIndex } )
51+ } , [ ] )
4652
47- return (
48- < button
49- key = { tab . path }
50- className = { `file-tab flex items-center gap-1.5 px-3 py-2 text-xs flex-shrink-0 transition-colors relative select-none${ isActive ? ' active' : '' } ` }
51- style = { {
52- maxWidth : '200px' ,
53- minWidth : '80px' ,
54- borderBottom : isActive ? '2px solid var(--evo-green)' : '2px solid transparent' ,
55- color : isActive ? 'var(--text-primary)' : 'var(--text-muted)' ,
56- background : isActive ? 'var(--surface-active)' : 'transparent' ,
57- cursor : 'pointer' ,
58- whiteSpace : 'nowrap' ,
59- } }
60- onClick = { ( ) => onSwitch ( tab . path ) }
61- onMouseDown = { e => {
62- if ( e . button === 1 ) {
63- e . preventDefault ( )
64- onClose ( tab . path )
65- }
66- } }
67- onMouseEnter = { e => {
68- if ( ! isActive ) e . currentTarget . style . background = 'var(--surface-hover)'
69- } }
70- onMouseLeave = { e => {
71- if ( ! isActive ) e . currentTarget . style . background = 'transparent'
72- } }
73- title = { tab . path }
74- >
75- { /* Dirty indicator dot */ }
76- { isDirty && (
77- < span
78- style = { {
79- width : '6px' ,
80- height : '6px' ,
81- borderRadius : '50%' ,
82- background : 'var(--warning)' ,
83- flexShrink : 0 ,
84- display : 'inline-block' ,
85- } }
86- />
87- ) }
53+ if ( tabs . length === 0 ) return null
8854
89- { /* File name */ }
90- < span
91- className = "truncate flex-1 min-w-0"
92- style = { { maxWidth : isDirty ? '130px' : '150px' } }
93- >
94- { fileName ( tab . path ) }
95- </ span >
55+ const hasLeft = ctxMenu ? ctxMenu . tabIndex > 0 : false
56+ const hasRight = ctxMenu ? ctxMenu . tabIndex < tabs . length - 1 : false
57+ const hasOthers = tabs . length > 1
58+
59+ return (
60+ < >
61+ < div
62+ ref = { scrollRef }
63+ className = "flex items-end overflow-x-auto flex-shrink-0 file-tabs-scroll"
64+ style = { {
65+ background : 'var(--bg-card)' ,
66+ borderBottom : '1px solid var(--border)' ,
67+ scrollbarWidth : 'none' ,
68+ msOverflowStyle : 'none' ,
69+ minHeight : '36px' ,
70+ } }
71+ >
72+ < style > { `
73+ .file-tabs-scroll::-webkit-scrollbar { display: none; }
74+ .file-tab-close { opacity: 0; }
75+ .file-tab:hover .file-tab-close,
76+ .file-tab.active .file-tab-close { opacity: 1; }
77+ ` } </ style >
78+ { tabs . map ( ( tab , index ) => {
79+ const isActive = tab . path === activePath
80+ const isDirty = dirtyPaths . has ( tab . path )
9681
97- { /* Close button */ }
98- < span
99- className = "file-tab-close flex items-center justify-center flex-shrink-0 rounded transition-colors"
82+ return (
83+ < button
84+ key = { tab . path }
85+ className = { `file-tab flex items-center gap-1.5 px-3 py-2 text-xs flex-shrink-0 transition-colors relative select-none${ isActive ? ' active' : '' } ` }
10086 style = { {
101- width : '16px' ,
102- height : '16px' ,
87+ maxWidth : '200px' ,
88+ minWidth : '80px' ,
89+ borderBottom : isActive ? '2px solid var(--evo-green)' : '2px solid transparent' ,
90+ color : isActive ? 'var(--text-primary)' : 'var(--text-muted)' ,
91+ background : isActive ? 'var(--surface-active)' : 'transparent' ,
92+ cursor : 'pointer' ,
93+ whiteSpace : 'nowrap' ,
10394 } }
104- onClick = { e => {
105- e . stopPropagation ( )
106- onClose ( tab . path )
95+ onClick = { ( ) => onSwitch ( tab . path ) }
96+ onContextMenu = { e => handleContextMenu ( e , tab . path , index ) }
97+ onMouseDown = { e => {
98+ if ( e . button === 1 ) {
99+ e . preventDefault ( )
100+ onClose ( tab . path )
101+ }
107102 } }
108103 onMouseEnter = { e => {
109- e . currentTarget . style . background = 'var(--border )'
104+ if ( ! isActive ) e . currentTarget . style . background = 'var(--surface-hover )'
110105 } }
111106 onMouseLeave = { e => {
112- e . currentTarget . style . background = 'transparent'
107+ if ( ! isActive ) e . currentTarget . style . background = 'transparent'
113108 } }
109+ title = { tab . path }
114110 >
115- < X size = { 10 } />
116- </ span >
117- </ button >
118- )
119- } ) }
120- </ div >
111+ { isDirty && (
112+ < span
113+ style = { {
114+ width : '6px' ,
115+ height : '6px' ,
116+ borderRadius : '50%' ,
117+ background : 'var(--warning)' ,
118+ flexShrink : 0 ,
119+ display : 'inline-block' ,
120+ } }
121+ />
122+ ) }
123+
124+ < span
125+ className = "truncate flex-1 min-w-0"
126+ style = { { maxWidth : isDirty ? '130px' : '150px' } }
127+ >
128+ { fileName ( tab . path ) }
129+ </ span >
130+
131+ < span
132+ className = "file-tab-close flex items-center justify-center flex-shrink-0 rounded transition-colors"
133+ style = { { width : '16px' , height : '16px' } }
134+ onClick = { e => {
135+ e . stopPropagation ( )
136+ onClose ( tab . path )
137+ } }
138+ onMouseEnter = { e => { e . currentTarget . style . background = 'var(--border)' } }
139+ onMouseLeave = { e => { e . currentTarget . style . background = 'transparent' } }
140+ >
141+ < X size = { 10 } />
142+ </ span >
143+ </ button >
144+ )
145+ } ) }
146+ </ div >
147+
148+ { /* Context menu */ }
149+ { ctxMenu && (
150+ < div
151+ className = "fixed z-[100] rounded-lg border shadow-xl py-1"
152+ style = { {
153+ left : ctxMenu . x ,
154+ top : ctxMenu . y ,
155+ background : 'var(--bg-card)' ,
156+ borderColor : 'var(--border)' ,
157+ minWidth : '200px' ,
158+ boxShadow : '0 8px 32px rgba(0,0,0,0.4)' ,
159+ } }
160+ >
161+ < CtxMenuItem
162+ label = "Fechar"
163+ shortcut = "Ctrl+W"
164+ onClick = { ( ) => { onClose ( ctxMenu . tabPath ) ; setCtxMenu ( null ) } }
165+ />
166+ { hasOthers && onCloseOthers && (
167+ < CtxMenuItem
168+ label = "Fechar outras"
169+ onClick = { ( ) => { onCloseOthers ( ctxMenu . tabPath ) ; setCtxMenu ( null ) } }
170+ />
171+ ) }
172+ { hasLeft && onCloseToLeft && (
173+ < CtxMenuItem
174+ label = "Fechar todas à esquerda"
175+ onClick = { ( ) => { onCloseToLeft ( ctxMenu . tabPath ) ; setCtxMenu ( null ) } }
176+ />
177+ ) }
178+ { hasRight && onCloseToRight && (
179+ < CtxMenuItem
180+ label = "Fechar todas à direita"
181+ onClick = { ( ) => { onCloseToRight ( ctxMenu . tabPath ) ; setCtxMenu ( null ) } }
182+ />
183+ ) }
184+ < div style = { { borderTop : '1px solid var(--border)' , margin : '4px 0' } } />
185+ { onCloseAll && (
186+ < CtxMenuItem
187+ label = "Fechar todas"
188+ onClick = { ( ) => { onCloseAll ( ) ; setCtxMenu ( null ) } }
189+ />
190+ ) }
191+ </ div >
192+ ) }
193+ </ >
194+ )
195+ }
196+
197+ function CtxMenuItem ( { label, shortcut, onClick } : { label : string ; shortcut ?: string ; onClick : ( ) => void } ) {
198+ return (
199+ < button
200+ className = "w-full flex items-center justify-between px-3 py-1.5 text-xs transition-colors"
201+ style = { { color : 'var(--text-secondary)' } }
202+ onClick = { onClick }
203+ onMouseEnter = { e => { e . currentTarget . style . background = 'var(--surface-hover)' ; e . currentTarget . style . color = 'var(--text-primary)' } }
204+ onMouseLeave = { e => { e . currentTarget . style . background = 'transparent' ; e . currentTarget . style . color = 'var(--text-secondary)' } }
205+ >
206+ < span > { label } </ span >
207+ { shortcut && < span style = { { color : 'var(--text-muted)' , fontSize : '10px' } } > { shortcut } </ span > }
208+ </ button >
121209 )
122210}
0 commit comments