feat: allow multiple terminal tabs per connection

This commit is contained in:
liumangmang
2026-03-26 18:04:39 +08:00
parent 93cc13ddd0
commit 78e6fc3e47
5 changed files with 219 additions and 19 deletions

View File

@@ -0,0 +1,101 @@
# Terminal Multi Tabs Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow the connection list to open multiple terminal tabs for the same connection, with each click creating a fresh terminal session.
**Architecture:** Keep the existing `/terminal` workspace and Pinia-driven tab model, but change terminal tab creation from connection-deduplicated to always-new. Add lightweight title numbering so repeated tabs for the same connection remain distinguishable without changing routing or terminal widget lifecycle.
**Tech Stack:** Vue 3, TypeScript, Pinia, Vue Router, xterm.js, Vite, vue-tsc
---
## File Structure
- Modify: `frontend/src/stores/terminalTabs.ts`
- Change tab creation semantics and add repeated-title numbering.
- Modify: `frontend/src/views/ConnectionsView.vue`
- Use the new always-open terminal action from the connection list.
- Verify: `frontend/src/views/TerminalWorkspaceView.vue`
- Confirm current per-tab widget mounting already supports independent sessions.
- Verify: `frontend/src/layouts/MainLayout.vue`
- Confirm terminal sidebar tab rendering needs no structure change beyond new titles.
- Reference: `docs/superpowers/specs/2026-03-26-terminal-multi-tabs-design.md`
- Source of truth for behavior and acceptance criteria.
### Task 1: Change Terminal Tabs Store to Always Create New Tabs
**Files:**
- Modify: `frontend/src/stores/terminalTabs.ts`
- Reference: `frontend/src/stores/sftpTabs.ts`
- [ ] **Step 1: Inspect current tab store behavior**
Read `frontend/src/stores/terminalTabs.ts` and confirm the existing dedup-by-connection behavior.
- [ ] **Step 2: Add repeated-title generation**
Add a helper that counts currently open tabs for the same `connectionId` and returns:
```ts
function getTabTitle(connection: Connection) {
const sameConnectionCount = tabs.value.filter(t => t.connectionId === connection.id).length
if (sameConnectionCount === 0) {
return connection.name
}
return `${connection.name} (${sameConnectionCount + 1})`
}
```
- [ ] **Step 3: Replace dedup open logic with always-new creation**
Change the open action so it always creates a new `TerminalTab` and activates it immediately.
- [ ] **Step 4: Keep activate/close behavior unchanged**
Do not change tab activation and close-neighbor behavior except what is necessary to support the new open logic.
### Task 2: Wire the Connection List to the New Behavior
**Files:**
- Modify: `frontend/src/views/ConnectionsView.vue`
- Use: `frontend/src/stores/terminalTabs.ts`
- [ ] **Step 1: Rename the method usage to match semantics**
Update `openTerminal(conn)` to call the new always-open store action.
- [ ] **Step 2: Preserve routing behavior**
Keep `router.push('/terminal')` unchanged after opening the tab.
### Task 3: Verify Integration and Build
**Files:**
- Verify: `frontend/src/layouts/MainLayout.vue`
- Verify: `frontend/src/views/TerminalWorkspaceView.vue`
- [ ] **Step 1: Confirm sidebar rendering already keys by `tab.id`**
No structural code changes should be required if repeated tabs render correctly with distinct titles.
- [ ] **Step 2: Run final frontend build**
Run in `frontend/`:
```bash
npm run build
```
Expected: `vue-tsc -b` and `vite build` both pass.
- [ ] **Step 3: Manual runtime verification**
Check:
1. Same connection opens multiple terminal tabs.
2. Repeated tabs are titled distinctly.
3. Each tab remains an independent terminal session.
4. Close/switch behavior still works.

View File

@@ -0,0 +1,97 @@
# 2026-03-26 Terminal Multi Tabs Design
## Background
The connection list already opens terminal sessions into the shared terminal workspace at `/terminal`, and the sidebar already renders terminal tabs. However, the current store deduplicates tabs by `connectionId`, so clicking `终端` for the same connection only focuses the existing tab instead of opening a new shell session.
## Goal
- Allow users to open multiple terminal tabs from the connection list for the same connection.
- Keep each terminal tab as an independent shell session.
- Preserve the current terminal workspace route and sidebar interaction model.
## Non-Goals
- No tab persistence across refresh.
- No route change from `/terminal` to per-tab URLs.
- No generic tab-system refactor shared with SFTP.
- No terminal session restore or reconnect workflow.
## Current State
- `frontend/src/stores/terminalTabs.ts` manages terminal tabs.
- `openOrFocus(connection)` reuses an existing tab when `connectionId` matches.
- `frontend/src/views/ConnectionsView.vue` uses that method before routing to `/terminal`.
- `frontend/src/views/TerminalWorkspaceView.vue` already mounts one `TerminalWidget` per tab keyed by `tab.id`, so multiple tabs can coexist as long as the store allows them.
## Selected Approach
Keep the existing terminal workspace architecture and change only the terminal tab creation semantics: clicking `终端` always creates a new tab, even when the same connection already has open tabs.
### 1. Terminal tab creation becomes always-new
Update `frontend/src/stores/terminalTabs.ts` so the terminal action always creates a new tab entry:
- remove the `connectionId` dedup behavior from the open action
- generate a fresh `tab.id` every time
- activate the newly created tab immediately
This preserves the current in-memory tab lifecycle while enabling multiple concurrent sessions to the same host.
### 2. Distinguishable tab titles for repeated connections
When the same connection is opened multiple times, sidebar labels must remain distinguishable.
Recommended title strategy:
- first tab: `Connection Name`
- second tab: `Connection Name (2)`
- third tab: `Connection Name (3)`
The sequence is computed from currently open tabs for the same `connectionId`. No persistence is needed.
### 3. Connections entry behavior
Update `frontend/src/views/ConnectionsView.vue` so clicking `终端` calls the always-new tab action and routes to `/terminal`.
Result:
- each click from the connection list opens a fresh shell session
- users can intentionally keep several terminals open for the same host
### 4. Terminal workspace behavior stays unchanged
`frontend/src/views/TerminalWorkspaceView.vue` and `frontend/src/components/TerminalWidget.vue` do not need architectural changes:
- the workspace already loops through tabs by `tab.id`
- each active tab renders its own `TerminalWidget`
- each widget maintains an independent xterm instance and WebSocket connection
This matches the desired UX: multiple tabs for the same connection are separate terminal sessions rather than alternate views of one shared session.
## Behavior Kept Unchanged
- Terminal route remains `/terminal`.
- Sidebar terminal tab section remains the primary tab switcher.
- Closing and activating adjacent terminal tabs continues to follow the current logic.
- SFTP tab behavior remains unchanged.
- Existing slate/cyan visual language remains unchanged.
## Acceptance Criteria
- Clicking `终端` for the same connection multiple times creates multiple terminal tabs.
- Each new terminal tab becomes the active tab.
- Repeated tabs for the same connection are visually distinguishable in the sidebar.
- Switching between tabs preserves each tab's own terminal session output.
- Closing a terminal tab keeps current close/activate behavior intact.
- Opening tabs for different connections continues to work.
## Verification
- Run `npm run build` in `frontend/`.
- Manual verification:
1. Click `终端` on the same connection three times and confirm three tabs appear.
2. Confirm the sidebar shows distinguishable titles for repeated tabs.
3. Type different commands in different tabs for the same connection and confirm each tab keeps its own output.
4. Close active and inactive terminal tabs and confirm focus changes remain correct.
5. Re-check that opening terminal tabs for different connections still works.

View File

@@ -19,19 +19,21 @@ export const useTerminalTabsStore = defineStore('terminalTabs', () => {
return `tab-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function openOrFocus(connection: Connection) {
// 检查是否已存在该连接的标签页
const existing = tabs.value.find(t => t.connectionId === connection.id)
if (existing) {
activate(existing.id)
return existing.id
function getTabTitle(connection: Connection) {
const sameConnectionCount = tabs.value.filter(t => t.connectionId === connection.id).length
if (sameConnectionCount === 0) {
return connection.name
}
// 创建新标签页
return `${connection.name} (${sameConnectionCount + 1})`
}
function openTab(connection: Connection) {
const newTab: TerminalTab = {
id: generateTabId(),
connectionId: connection.id,
title: connection.name,
title: getTabTitle(connection),
active: true,
}
@@ -67,7 +69,7 @@ export const useTerminalTabsStore = defineStore('terminalTabs', () => {
tabs,
activeTabId,
activeTab,
openOrFocus,
openTab,
activate,
close,
}

View File

@@ -92,7 +92,7 @@ async function handleDelete(conn: Connection) {
}
function openTerminal(conn: Connection) {
tabsStore.openOrFocus(conn)
tabsStore.openTab(conn)
router.push('/terminal')
}

View File

@@ -19,8 +19,8 @@ onMounted(async () => {
const conn = connectionsStore.getConnection(connectionId.value)
if (conn) {
// 打开或聚焦该连接的标签页
tabsStore.openOrFocus(conn)
// 打开新的终端标签页
tabsStore.openTab(conn)
// 跳转到工作区
router.replace('/terminal')
} else {