1717 */
1818
1919import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
20- import { Plus , SquareTerminal , Circle } from 'lucide-react' ;
20+ import { Plus , SquareTerminal , Circle , Trash2 , Square , Edit2 } from 'lucide-react' ;
21+ import { useTranslation } from 'react-i18next' ;
2122import { getTerminalService } from '../../../../../tools/terminal' ;
2223import type { TerminalService } from '../../../../../tools/terminal' ;
2324import type { SessionResponse , TerminalEvent } from '../../../../../tools/terminal/types/session' ;
@@ -27,6 +28,8 @@ import { resolveAndFocusOpenTarget } from '../../../../../shared/services/sceneO
2728import { useCurrentWorkspace } from '../../../../../infrastructure/contexts/WorkspaceContext' ;
2829import { configManager } from '../../../../../infrastructure/config/services/ConfigManager' ;
2930import type { TerminalConfig } from '../../../../../infrastructure/config/types' ;
31+ import { Tooltip } from '@/component-library' ;
32+ import { TerminalEditModal } from '../../../panels/TerminalEditModal' ;
3033import { createLogger } from '@/shared/utils/logger' ;
3134
3235const log = createLogger ( 'ShellsSection' ) ;
@@ -55,6 +58,14 @@ function loadHubConfig(workspacePath: string): HubConfig {
5558 return { terminals : [ ] , worktrees : { } } ;
5659}
5760
61+ function saveHubConfig ( workspacePath : string , config : HubConfig ) {
62+ try {
63+ localStorage . setItem ( `${ TERMINAL_HUB_STORAGE_KEY } :${ workspacePath } ` , JSON . stringify ( config ) ) ;
64+ } catch ( err ) {
65+ log . error ( 'Failed to save hub config' , err ) ;
66+ }
67+ }
68+
5869interface ShellEntry {
5970 sessionId : string ;
6071 name : string ;
@@ -65,11 +76,17 @@ interface ShellEntry {
6576}
6677
6778const ShellsSection : React . FC = ( ) => {
79+ const { t } = useTranslation ( 'panels/terminal' ) ;
6880 const setActiveSession = useTerminalSceneStore ( s => s . setActiveSession ) ;
6981 const { workspacePath } = useCurrentWorkspace ( ) ;
7082
7183 const [ sessions , setSessions ] = useState < SessionResponse [ ] > ( [ ] ) ;
7284 const [ hubConfig , setHubConfig ] = useState < HubConfig > ( { terminals : [ ] , worktrees : { } } ) ;
85+ const [ editModalOpen , setEditModalOpen ] = useState ( false ) ;
86+ const [ editingTerminal , setEditingTerminal ] = useState < {
87+ terminal : HubTerminalEntry ;
88+ worktreePath ?: string ;
89+ } | null > ( null ) ;
7390 const serviceRef = useRef < TerminalService | null > ( null ) ;
7491
7592 const runningIds = useMemo ( ( ) => new Set ( sessions . map ( s => s . id ) ) , [ sessions ] ) ;
@@ -241,6 +258,215 @@ const ShellsSection: React.FC = () => {
241258 }
242259 } , [ workspacePath , sessions . length , setActiveSession ] ) ;
243260
261+ /**
262+ * Stop terminal session
263+ * - For hub terminals: keep in list but stop the process, right panel stays open
264+ * - For ad-hoc terminals: same as delete (close session and right panel tab)
265+ */
266+ const handleStopTerminal = useCallback (
267+ async ( entry : ShellEntry , e : React . MouseEvent ) => {
268+ e . stopPropagation ( ) ;
269+ const service = serviceRef . current ;
270+ if ( ! service || ! runningIds . has ( entry . sessionId ) ) return ;
271+
272+ try {
273+ await service . closeSession ( entry . sessionId ) ;
274+
275+ // For ad-hoc terminals, dispatch destroyed event to close right panel tab
276+ // (since they won't be preserved in the list anyway)
277+ if ( ! entry . isHub ) {
278+ window . dispatchEvent (
279+ new CustomEvent ( 'terminal-session-destroyed' , { detail : { sessionId : entry . sessionId } } )
280+ ) ;
281+ }
282+
283+ await refreshSessions ( ) ;
284+ } catch ( err ) {
285+ log . error ( 'Failed to stop terminal' , err ) ;
286+ }
287+ } ,
288+ [ runningIds , refreshSessions ]
289+ ) ;
290+
291+ /**
292+ * Delete terminal - close session, close right panel tab, and remove from list
293+ * For hub terminals: also remove from localStorage config
294+ */
295+ const handleDeleteTerminal = useCallback (
296+ async ( entry : ShellEntry , e : React . MouseEvent ) => {
297+ e . stopPropagation ( ) ;
298+
299+ // Close the terminal session if running
300+ if ( entry . isRunning ) {
301+ const service = serviceRef . current ;
302+ if ( service ) {
303+ try {
304+ await service . closeSession ( entry . sessionId ) ;
305+ } catch ( err ) {
306+ log . error ( 'Failed to close terminal session' , err ) ;
307+ }
308+ }
309+ }
310+
311+ // Dispatch event to close the tab in right panel
312+ window . dispatchEvent (
313+ new CustomEvent ( 'terminal-session-destroyed' , { detail : { sessionId : entry . sessionId } } )
314+ ) ;
315+
316+ // For hub terminals, also remove from localStorage config
317+ if ( entry . isHub && workspacePath ) {
318+ setHubConfig ( prev => {
319+ let next : HubConfig ;
320+ if ( entry . worktreePath ) {
321+ const terms = ( prev . worktrees [ entry . worktreePath ] || [ ] ) . filter (
322+ t => t . sessionId !== entry . sessionId
323+ ) ;
324+ next = { ...prev , worktrees : { ...prev . worktrees , [ entry . worktreePath ] : terms } } ;
325+ } else {
326+ next = { ...prev , terminals : prev . terminals . filter ( t => t . sessionId !== entry . sessionId ) } ;
327+ }
328+ saveHubConfig ( workspacePath , next ) ;
329+ return next ;
330+ } ) ;
331+ }
332+
333+ // Refresh the session list
334+ await refreshSessions ( ) ;
335+ } ,
336+ [ workspacePath , refreshSessions ]
337+ ) ;
338+
339+ /**
340+ * Open edit modal for a terminal
341+ */
342+ const handleOpenEditModal = useCallback (
343+ ( entry : ShellEntry , e : React . MouseEvent ) => {
344+ e . stopPropagation ( ) ;
345+
346+ if ( entry . isHub ) {
347+ // For hub terminals, find the entry from config
348+ let hubEntry : HubTerminalEntry | undefined ;
349+ if ( entry . worktreePath ) {
350+ hubEntry = hubConfig . worktrees [ entry . worktreePath ] ?. find ( t => t . sessionId === entry . sessionId ) ;
351+ } else {
352+ hubEntry = hubConfig . terminals . find ( t => t . sessionId === entry . sessionId ) ;
353+ }
354+
355+ if ( hubEntry ) {
356+ setEditingTerminal ( { terminal : hubEntry , worktreePath : entry . worktreePath } ) ;
357+ setEditModalOpen ( true ) ;
358+ }
359+ } else {
360+ // For ad-hoc sessions, create a temporary entry for editing
361+ setEditingTerminal ( {
362+ terminal : { sessionId : entry . sessionId , name : entry . name } ,
363+ worktreePath : undefined ,
364+ } ) ;
365+ setEditModalOpen ( true ) ;
366+ }
367+ } ,
368+ [ hubConfig ]
369+ ) ;
370+
371+ /**
372+ * Save terminal edit (name and optionally startup command)
373+ */
374+ const handleSaveTerminalEdit = useCallback (
375+ ( newName : string , newStartupCommand ?: string ) => {
376+ if ( ! editingTerminal ) return ;
377+ const { terminal, worktreePath } = editingTerminal ;
378+
379+ // For hub terminals, update localStorage config
380+ if ( terminal . sessionId . startsWith ( HUB_TERMINAL_ID_PREFIX ) && workspacePath ) {
381+ setHubConfig ( prev => {
382+ let next : HubConfig ;
383+ if ( worktreePath ) {
384+ const terms = ( prev . worktrees [ worktreePath ] || [ ] ) . map ( t =>
385+ t . sessionId === terminal . sessionId
386+ ? { ...t , name : newName , startupCommand : newStartupCommand }
387+ : t
388+ ) ;
389+ next = { ...prev , worktrees : { ...prev . worktrees , [ worktreePath ] : terms } } ;
390+ } else {
391+ const terms = prev . terminals . map ( t =>
392+ t . sessionId === terminal . sessionId
393+ ? { ...t , name : newName , startupCommand : newStartupCommand }
394+ : t
395+ ) ;
396+ next = { ...prev , terminals : terms } ;
397+ }
398+ saveHubConfig ( workspacePath , next ) ;
399+ return next ;
400+ } ) ;
401+ }
402+
403+ // Update session name in state and notify other components
404+ if ( runningIds . has ( terminal . sessionId ) ) {
405+ setSessions ( prev =>
406+ prev . map ( s => ( s . id === terminal . sessionId ? { ...s , name : newName } : s ) )
407+ ) ;
408+ window . dispatchEvent (
409+ new CustomEvent ( 'terminal-session-renamed' , {
410+ detail : { sessionId : terminal . sessionId , newName } ,
411+ } )
412+ ) ;
413+ }
414+
415+ setEditingTerminal ( null ) ;
416+ } ,
417+ [ editingTerminal , workspacePath , runningIds ]
418+ ) ;
419+
420+ const renderTerminalItem = ( entry : ShellEntry ) => {
421+ return (
422+ < button
423+ key = { entry . sessionId }
424+ type = "button"
425+ className = "bitfun-nav-panel__inline-item"
426+ onClick = { ( ) => handleOpen ( entry ) }
427+ title = { entry . name }
428+ >
429+ < SquareTerminal size = { 12 } className = "bitfun-nav-panel__inline-item-icon" />
430+ < span className = "bitfun-nav-panel__inline-item-label" > { entry . name } </ span >
431+ < Circle
432+ size = { 6 }
433+ className = { `bitfun-nav-panel__shell-dot ${ entry . isRunning ? 'is-running' : 'is-stopped' } ` }
434+ />
435+ < div className = "bitfun-nav-panel__inline-item-actions" >
436+ < Tooltip content = { t ( 'actions.edit' ) } >
437+ < button
438+ type = "button"
439+ className = "bitfun-nav-panel__inline-item-action-btn"
440+ onClick = { ( e ) => handleOpenEditModal ( entry , e ) }
441+ >
442+ < Edit2 size = { 10 } />
443+ </ button >
444+ </ Tooltip >
445+ { entry . isRunning && (
446+ < Tooltip content = { t ( 'actions.stopTerminal' ) } >
447+ < button
448+ type = "button"
449+ className = "bitfun-nav-panel__inline-item-action-btn"
450+ onClick = { ( e ) => handleStopTerminal ( entry , e ) }
451+ >
452+ < Square size = { 10 } />
453+ </ button >
454+ </ Tooltip >
455+ ) }
456+ < Tooltip content = { t ( 'actions.deleteTerminal' ) } >
457+ < button
458+ type = "button"
459+ className = "bitfun-nav-panel__inline-item-action-btn delete"
460+ onClick = { ( e ) => handleDeleteTerminal ( entry , e ) }
461+ >
462+ < Trash2 size = { 10 } />
463+ </ button >
464+ </ Tooltip >
465+ </ div >
466+ </ button >
467+ ) ;
468+ } ;
469+
244470 return (
245471 < div className = "bitfun-nav-panel__inline-list" >
246472 < button
@@ -256,22 +482,20 @@ const ShellsSection: React.FC = () => {
256482 { entries . length === 0 ? (
257483 < div className = "bitfun-nav-panel__inline-empty" > No shells</ div >
258484 ) : (
259- entries . map ( entry => (
260- < button
261- key = { entry . sessionId }
262- type = "button"
263- className = "bitfun-nav-panel__inline-item"
264- onClick = { ( ) => handleOpen ( entry ) }
265- title = { entry . name }
266- >
267- < SquareTerminal size = { 12 } className = "bitfun-nav-panel__inline-item-icon" />
268- < span className = "bitfun-nav-panel__inline-item-label" > { entry . name } </ span >
269- < Circle
270- size = { 6 }
271- className = { `bitfun-nav-panel__shell-dot ${ entry . isRunning ? 'is-running' : 'is-stopped' } ` }
272- />
273- </ button >
274- ) )
485+ entries . map ( entry => renderTerminalItem ( entry ) )
486+ ) }
487+
488+ { editingTerminal && (
489+ < TerminalEditModal
490+ isOpen = { editModalOpen }
491+ onClose = { ( ) => {
492+ setEditModalOpen ( false ) ;
493+ setEditingTerminal ( null ) ;
494+ } }
495+ onSave = { handleSaveTerminalEdit }
496+ initialName = { editingTerminal . terminal . name }
497+ initialStartupCommand = { editingTerminal . terminal . startupCommand }
498+ />
275499 ) }
276500 </ div >
277501 ) ;
0 commit comments