Skip to content

Commit 4209a63

Browse files
author
吴糖可乐
committed
fix: The established terminal cannot be deleted and the terminal display is disordered
1 parent e3891d6 commit 4209a63

8 files changed

Lines changed: 367 additions & 64 deletions

File tree

src/web-ui/src/app/components/NavPanel/sections/shells/ShellsSection.tsx

Lines changed: 241 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
*/
1818

1919
import 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';
2122
import { getTerminalService } from '../../../../../tools/terminal';
2223
import type { TerminalService } from '../../../../../tools/terminal';
2324
import type { SessionResponse, TerminalEvent } from '../../../../../tools/terminal/types/session';
@@ -27,6 +28,8 @@ import { resolveAndFocusOpenTarget } from '../../../../../shared/services/sceneO
2728
import { useCurrentWorkspace } from '../../../../../infrastructure/contexts/WorkspaceContext';
2829
import { configManager } from '../../../../../infrastructure/config/services/ConfigManager';
2930
import type { TerminalConfig } from '../../../../../infrastructure/config/types';
31+
import { Tooltip } from '@/component-library';
32+
import { TerminalEditModal } from '../../../panels/TerminalEditModal';
3033
import { createLogger } from '@/shared/utils/logger';
3134

3235
const 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+
5869
interface ShellEntry {
5970
sessionId: string;
6071
name: string;
@@ -65,11 +76,17 @@ interface ShellEntry {
6576
}
6677

6778
const 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
);

src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,7 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
706706
<React.Suspense fallback={<div className="bitfun-flexible-panel__loading">{t('flexiblePanel.loading.terminal')}</div>}>
707707
<div className="bitfun-flexible-panel__terminal-container">
708708
<TerminalTabPanel
709+
key={sessionId}
709710
sessionId={sessionId}
710711
autoFocus={true}
711712
/>

src/web-ui/src/app/components/panels/content-canvas/hooks/useTabLifecycle.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import { useCallback, useEffect } from 'react';
12-
import { useCanvasStore } from '../stores';
12+
import { useCanvasStore, useAgentCanvasStore, useProjectCanvasStore, useGitCanvasStore } from '../stores';
1313
import type { EditorGroupId, PanelContent, CreateTabEventDetail } from '../types';
1414
import { TAB_EVENTS } from '../types';
1515
import { createLogger } from '@/shared/utils/logger';
@@ -185,17 +185,41 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif
185185
* Listen for left-panel terminal close events to sync right-panel tabs.
186186
*/
187187
useEffect(() => {
188+
const store = mode === 'project' ? useProjectCanvasStore
189+
: mode === 'git' ? useGitCanvasStore
190+
: useAgentCanvasStore;
191+
188192
const handleTerminalSessionDestroyed = (event: CustomEvent<{ sessionId: string }>) => {
189193
const { sessionId } = event.detail ?? {};
190194
if (sessionId) {
191-
useCanvasStore.getState().closeTerminalTabBySessionId(sessionId);
195+
store.getState().closeTerminalTabBySessionId(sessionId);
192196
}
193197
};
194198
window.addEventListener('terminal-session-destroyed', handleTerminalSessionDestroyed as EventListener);
195199
return () => {
196200
window.removeEventListener('terminal-session-destroyed', handleTerminalSessionDestroyed as EventListener);
197201
};
198-
}, []);
202+
}, [mode]);
203+
204+
/**
205+
* Listen for left-panel terminal rename events to sync right-panel tabs.
206+
*/
207+
useEffect(() => {
208+
const store = mode === 'project' ? useProjectCanvasStore
209+
: mode === 'git' ? useGitCanvasStore
210+
: useAgentCanvasStore;
211+
212+
const handleTerminalSessionRenamed = (event: CustomEvent<{ sessionId: string; newName: string }>) => {
213+
const { sessionId, newName } = event.detail ?? {};
214+
if (sessionId && newName) {
215+
store.getState().renameTerminalTabBySessionId(sessionId, newName);
216+
}
217+
};
218+
window.addEventListener('terminal-session-renamed', handleTerminalSessionRenamed as EventListener);
219+
return () => {
220+
window.removeEventListener('terminal-session-renamed', handleTerminalSessionRenamed as EventListener);
221+
};
222+
}, [mode]);
199223

200224
/**
201225
* Listen for external tab creation events.

src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ interface CanvasStoreActions {
5353

5454
/** Close and remove tab by terminal sessionId (sync when left panel closes terminal) */
5555
closeTerminalTabBySessionId: (sessionId: string) => void;
56+
57+
/** Rename terminal tab by sessionId (sync when left panel renames terminal) */
58+
renameTerminalTabBySessionId: (sessionId: string, newName: string) => void;
5659

5760
/** Close all tabs */
5861
closeAllTabs: (groupId?: EditorGroupId) => void;
@@ -457,6 +460,22 @@ const createCanvasStoreHook = () => create<CanvasStore>()(
457460
if (!result || result.tab.content.type !== 'terminal') return;
458461
state.closeTab(result.tab.id, result.groupId, { forceRemove: true });
459462
},
463+
464+
renameTerminalTabBySessionId: (sessionId, newName) => {
465+
const result = get().findTabByMetadata({ sessionId });
466+
if (!result || result.tab.content.type !== 'terminal') return;
467+
468+
set((draft) => {
469+
const group = getGroup(draft, result.groupId);
470+
const tab = group.tabs.find(t => t.id === result.tab.id);
471+
if (tab) {
472+
const displayTitle = newName.length > 20 ? `${newName.slice(0, 20)}...` : newName;
473+
tab.title = displayTitle;
474+
tab.content.title = displayTitle;
475+
tab.content.data = { ...tab.content.data, sessionName: newName };
476+
}
477+
});
478+
},
460479

461480
closeAllTabs: (groupId) => {
462481
set((draft) => {

0 commit comments

Comments
 (0)