@@ -4,7 +4,7 @@ import { FitAddon } from '@xterm/addon-fit'
44import { WebLinksAddon } from '@xterm/addon-web-links'
55import { SearchAddon } from '@xterm/addon-search'
66import '@xterm/xterm/css/xterm.css'
7- import { Search , X , ChevronUp , ChevronDown } from 'lucide-react'
7+ import { Search , X , ChevronUp , ChevronDown , Copy , Clipboard , Eraser } from 'lucide-react'
88import { toast } from 'sonner'
99import { Session } from '../../types'
1010import { useAppStore } from '../../store'
@@ -50,6 +50,9 @@ export function TerminalTab({ session }: Props): JSX.Element {
5050 const [ searchRegex , setSearchRegex ] = useState ( false )
5151 const searchInputRef = useRef < HTMLInputElement > ( null )
5252
53+ // ── Context menu state ────────────────────────────────────────────────────────
54+ const [ ctxMenu , setCtxMenu ] = useState < { x : number ; y : number } | null > ( null )
55+
5356 const { setSessionStatus, setSessionLogging } = useAppStore ( )
5457
5558 // Derive logging state from the session's loggingPath (store is source of truth)
@@ -128,6 +131,41 @@ export function TerminalTab({ session }: Props): JSX.Element {
128131 termRef . current ?. focus ( )
129132 } , [ ] )
130133
134+ // ── Context menu handlers ─────────────────────────────────────────────────────
135+ const handleContextMenu = useCallback ( ( e : React . MouseEvent ) => {
136+ e . preventDefault ( )
137+ setCtxMenu ( { x : e . clientX , y : e . clientY } )
138+ } , [ ] )
139+
140+ const ctxCopy = useCallback ( ( ) => {
141+ const sel = termRef . current ?. getSelection ( )
142+ if ( sel ) navigator . clipboard . writeText ( sel )
143+ setCtxMenu ( null )
144+ termRef . current ?. focus ( )
145+ } , [ ] )
146+
147+ const ctxPaste = useCallback ( async ( ) => {
148+ setCtxMenu ( null )
149+ const text = await navigator . clipboard . readText ( )
150+ if ( ! text ) return
151+ const proto = session . connection . protocol
152+ if ( proto === 'ssh' ) window . api . ssh . send ( session . id , text )
153+ else if ( proto === 'serial' ) window . api . serial . send ( session . id , text )
154+ else window . api . telnet . send ( session . id , text )
155+ termRef . current ?. focus ( )
156+ } , [ session . id , session . connection . protocol ] )
157+
158+ const ctxClear = useCallback ( ( ) => {
159+ termRef . current ?. clear ( )
160+ setCtxMenu ( null )
161+ termRef . current ?. focus ( )
162+ } , [ ] )
163+
164+ const ctxSearch = useCallback ( ( ) => {
165+ setCtxMenu ( null )
166+ openSearch ( )
167+ } , [ openSearch ] )
168+
131169 // ── Logging helpers ───────────────────────────────────────────────────────────
132170 const startLogging = async ( ) => {
133171 const filePath = await window . api . log . start ( session . connection . name )
@@ -803,11 +841,101 @@ export function TerminalTab({ session }: Props): JSX.Element {
803841 </ div >
804842
805843 { /* Terminal */ }
806- < div
807- ref = { containerRef }
808- className = "flex-1 w-full min-h-0 overflow-hidden bg-[#0B0718]"
809- style = { { fontVariantLigatures : 'none' } }
810- />
844+ < div className = "relative flex-1 w-full min-h-0 overflow-hidden" >
845+ < div
846+ ref = { containerRef }
847+ onContextMenu = { handleContextMenu }
848+ className = "w-full h-full bg-[#0B0718]"
849+ style = { { fontVariantLigatures : 'none' } }
850+ />
851+
852+ { /* Connecting overlay */ }
853+ { session . status === 'connecting' && (
854+ < div className = "absolute inset-0 z-10 flex flex-col items-center justify-center bg-[#0B0718]/90 backdrop-blur-sm gap-4" >
855+ < div className = "relative" >
856+ < div className = "w-12 h-12 rounded-full border-2 border-primary/20" />
857+ < div className = "absolute inset-0 w-12 h-12 rounded-full border-2 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin" />
858+ </ div >
859+ < div className = "flex flex-col items-center gap-1" >
860+ < span className = "text-sm font-medium text-foreground" > Connecting…</ span >
861+ < span className = "text-xs text-muted-foreground font-mono" >
862+ { session . connection . username ? `${ session . connection . username } @` : '' } { session . connection . host }
863+ </ span >
864+ </ div >
865+ </ div >
866+ ) }
867+
868+ { /* Disconnected overlay */ }
869+ { session . status === 'disconnected' && (
870+ < div className = "absolute inset-0 z-10 flex flex-col items-center justify-center bg-[#0B0718]/90 backdrop-blur-sm gap-4" >
871+ < div className = "w-12 h-12 rounded-full bg-red-500/10 border border-red-500/30 flex items-center justify-center" >
872+ < svg className = "w-6 h-6 text-red-400" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 1.5 } >
873+ < path strokeLinecap = "round" strokeLinejoin = "round" d = "M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757" />
874+ < path strokeLinecap = "round" strokeLinejoin = "round" d = "M9.75 15.312a4.5 4.5 0 0 1-1.242-7.244l4.5-4.5a4.5 4.5 0 0 1 6.364 6.364l-1.757 1.757" />
875+ </ svg >
876+ </ div >
877+ < div className = "flex flex-col items-center gap-1.5" >
878+ < span className = "text-sm font-medium text-foreground" > Disconnected</ span >
879+ < span className = "text-xs text-muted-foreground font-mono" >
880+ { session . connection . host }
881+ </ span >
882+ </ div >
883+ { session . connection . autoReconnect ? (
884+ < div className = "flex items-center gap-1.5 text-xs text-muted-foreground" >
885+ < span className = "w-1.5 h-1.5 rounded-full bg-yellow-500 animate-pulse" />
886+ Reconnecting…
887+ </ div >
888+ ) : (
889+ < button
890+ onClick = { ( ) => doConnectRef . current ?.( true ) }
891+ className = "flex items-center gap-2 px-4 py-2 rounded-lg bg-card border border-border text-sm text-foreground hover:bg-accent transition-colors cursor-pointer"
892+ >
893+ Reconnect
894+ </ button >
895+ ) }
896+ </ div >
897+ ) }
898+
899+ { /* Error overlay */ }
900+ { session . status === 'error' && (
901+ < div className = "absolute inset-0 z-10 flex flex-col items-center justify-center bg-[#0B0718]/90 backdrop-blur-sm gap-4" >
902+ < div className = "w-12 h-12 rounded-full bg-red-500/10 border border-red-500/30 flex items-center justify-center" >
903+ < svg className = "w-6 h-6 text-red-400" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 1.5 } >
904+ < path strokeLinecap = "round" strokeLinejoin = "round" d = "M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
905+ </ svg >
906+ </ div >
907+ < div className = "flex flex-col items-center gap-1.5" >
908+ < span className = "text-sm font-medium text-foreground" > Connection Error</ span >
909+ { session . error && (
910+ < span className = "text-xs text-muted-foreground font-mono max-w-xs text-center" > { session . error } </ span >
911+ ) }
912+ </ div >
913+ < button
914+ onClick = { ( ) => doConnectRef . current ?.( true ) }
915+ className = "flex items-center gap-2 px-4 py-2 rounded-lg bg-card border border-border text-sm text-foreground hover:bg-accent transition-colors cursor-pointer"
916+ >
917+ Retry
918+ </ button >
919+ </ div >
920+ ) }
921+ </ div >
922+
923+ { /* Context menu */ }
924+ { ctxMenu && (
925+ < >
926+ < div className = "fixed inset-0 z-40" onClick = { ( ) => { setCtxMenu ( null ) ; termRef . current ?. focus ( ) } } />
927+ < div
928+ className = "fixed z-50 bg-popover border border-border rounded-xl shadow-2xl py-1 w-44 overflow-hidden"
929+ style = { { top : ctxMenu . y , left : ctxMenu . x } }
930+ >
931+ < CtxItem icon = { Copy } label = "Copy" onClick = { ctxCopy } hint = "⌘C" />
932+ < CtxItem icon = { Clipboard } label = "Paste" onClick = { ctxPaste } hint = "⌘V" />
933+ < div className = "h-px bg-border/60 my-1 mx-2" />
934+ < CtxItem icon = { Search } label = "Search" onClick = { ctxSearch } hint = "⌘F" />
935+ < CtxItem icon = { Eraser } label = "Clear" onClick = { ctxClear } />
936+ </ div >
937+ </ >
938+ ) }
811939
812940 { promptState . visible && (
813941 < PasswordPrompt
@@ -820,3 +948,21 @@ export function TerminalTab({ session }: Props): JSX.Element {
820948 </ div >
821949 )
822950}
951+
952+ function CtxItem ( { icon : Icon , label, onClick, hint } : {
953+ icon : React . ComponentType < { className ?: string } >
954+ label : string
955+ onClick : ( ) => void
956+ hint ?: string
957+ } ) : JSX . Element {
958+ return (
959+ < button
960+ onClick = { onClick }
961+ className = "w-full flex items-center gap-2.5 px-3 py-2 text-sm text-foreground hover:bg-accent transition-colors text-left cursor-pointer"
962+ >
963+ < Icon className = "w-3.5 h-3.5 text-muted-foreground shrink-0" />
964+ < span className = "flex-1" > { label } </ span >
965+ { hint && < span className = "text-[11px] text-muted-foreground/50 font-mono" > { hint } </ span > }
966+ </ button >
967+ )
968+ }
0 commit comments