feat(frontend): polish connection tree and terminal groups

Add explorer-style tree search in the connections view with
match-path expansion, clearer count highlighting, and a refined
sidebar header layout.

Improve terminal tab grouping by keeping new sessions appended
within their server group, highlighting the active group, and
deduplicating broadcast actions to send commands once per server.
This commit is contained in:
yinjianm
2026-03-25 23:19:53 +08:00
parent 385e916c54
commit 1662b2b9e8
15 changed files with 563 additions and 83 deletions
@@ -64,6 +64,14 @@ const showConnectionListPopup = ref(false); // 连接列表弹出状态
const draggableSessions = ref<SessionTabInfoWithStatus[]>([]); // + Local state for draggable
const showTransferProgressModal = ref(false); // 控制传输进度模态框的显示状态
const activeConnectionId = computed(() => {
if (!props.activeSessionId) {
return null;
}
return sessionStore.sessions.get(props.activeSessionId)?.connectionId ?? null;
});
const openConnectionPicker = () => {
showConnectionListPopup.value = true;
};
@@ -481,13 +489,32 @@ onBeforeUnmount(() => {
<template #item="{ element: session, index }">
<li
:key="session.sessionId"
class="flex h-full flex-shrink-0 items-stretch py-1 pl-1"
:class="['flex h-full flex-shrink-0 items-stretch py-1', isGroupStart(index) ? 'pl-1' : 'pl-0']"
@dragstart="handleDragStart"
>
<div class="flex h-full items-stretch overflow-hidden rounded-md border border-border/70 bg-header/80 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]">
<div
:class="[
'flex h-full items-stretch overflow-hidden border transition-all duration-150',
session.connectionId === activeConnectionId
? 'border-primary/60 bg-primary/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'border-border/70 bg-header/80 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]',
isGroupStart(index) && isGroupEnd(index)
? 'rounded-md'
: isGroupStart(index)
? 'rounded-l-md rounded-r-none'
: isGroupEnd(index)
? '-ml-px rounded-r-md rounded-l-none'
: '-ml-px rounded-none',
]"
>
<div
v-if="isGroupStart(index)"
class="flex max-w-[160px] items-center border-r border-border/70 bg-black/15 px-2.5 text-xs font-semibold tracking-wide text-text-secondary"
:class="[
'flex max-w-[160px] items-center border-r px-2.5 text-xs font-semibold tracking-wide',
session.connectionId === activeConnectionId
? 'border-primary/50 bg-primary/15 text-foreground'
: 'border-border/70 bg-black/15 text-text-secondary',
]"
:title="session.connectionName"
>
<i class="fas fa-server mr-1.5 text-[10px] text-primary/80"></i>
@@ -496,9 +523,11 @@ onBeforeUnmount(() => {
<div
:class="[
'group flex h-full items-center px-3 transition-colors duration-150 relative',
'group flex h-full items-center px-2.5 transition-colors duration-150 relative',
session.sessionId === activeSessionId
? 'bg-background text-foreground'
? 'bg-background text-foreground shadow-[inset_0_1px_0_rgba(34,197,94,0.15)]'
: session.connectionId === activeConnectionId
? 'bg-primary/5 text-foreground/85 hover:bg-primary/10'
: 'bg-header text-text-secondary hover:bg-border',
!isGroupStart(index) ? 'border-l border-border/60' : '',
]"
@@ -513,7 +542,7 @@ onBeforeUnmount(() => {
session.status === 'connected' ? 'bg-green-500' :
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
<span class="whitespace-nowrap text-xs font-medium">
<span class="whitespace-nowrap text-[11px] font-medium">
{{ t('terminalTabBar.terminalBadge', { index: session.terminalIndex }) }}
</span>
<button
@@ -532,7 +561,12 @@ onBeforeUnmount(() => {
<button
v-if="isGroupEnd(index) && canOpenSiblingTerminal(session.connectionId)"
type="button"
class="flex h-full items-center border-l border-border/60 bg-black/10 px-2.5 text-text-secondary transition-colors duration-150 hover:bg-border hover:text-foreground"
:class="[
'flex h-full items-center border-l px-2.5 transition-colors duration-150',
session.connectionId === activeConnectionId
? 'border-primary/40 bg-primary/10 text-foreground/80 hover:bg-primary/15 hover:text-foreground'
: 'border-border/60 bg-black/10 text-text-secondary hover:bg-border hover:text-foreground',
]"
@click.stop="openNewTerminalForConnection(session.connectionId)"
:title="t('terminalTabBar.newTerminalTooltip')"
>
+4 -4
View File
@@ -1315,10 +1315,10 @@
"copied": "Copied to clipboard",
"copyFailed": "Copy failed",
"actions": {
"sendToAllSessions": "Send to All Sessions"
"sendToAllSessions": "Send to All Servers"
},
"notifications": {
"sentToAllSessions": "Command sent to {count} sessions.",
"sentToAllSessions": "Command sent to {count} servers.",
"noActiveSshSessions": "No active SSH sessions to send command to."
}
},
@@ -1356,10 +1356,10 @@
"clickToEditTag": "Click to edit tag name"
},
"actions": {
"sendToAllSessions": "Send to All Sessions"
"sendToAllSessions": "Send to All Servers"
},
"notifications": {
"sentToAllSessions": "Command sent to {count} sessions.",
"sentToAllSessions": "Command sent to {count} servers.",
"noActiveSshSessions": "No active SSH sessions to send command to."
}
},
+5 -5
View File
@@ -60,10 +60,10 @@
"loading": "ロード中...",
"searchPlaceholder": "履歴を検索...",
"actions": {
"sendToAllSessions": "すべてのセッションに送信"
"sendToAllSessions": "すべてのサーバーに送信"
},
"notifications": {
"sentToAllSessions": "コマンドは {count} 個のセッションに送信されました。",
"sentToAllSessions": "コマンドは {count} 台のサーバーに送信されました。",
"noActiveSshSessions": "コマンドを送信するアクティブな SSH セッションはありません。"
}
},
@@ -730,13 +730,13 @@
"sortByUsage": "使用頻度",
"usageCount": "使用回数",
"actions": {
"sendToAllSessions": "すべてのセッションに送信"
"sendToAllSessions": "すべてのサーバーに送信"
},
"notifications": {
"sentToAllSessions": "コマンドは {count} 個のセッションに送信されました。",
"sentToAllSessions": "コマンドは {count} 台のサーバーに送信されました。",
"noActiveSshSessions": "コマンドを送信するアクティブな SSH セッションはありません。"
}
},
},
"remoteDesktopModal": {
"errors": {
"clientError": "クライアントエラー",
+4 -4
View File
@@ -1319,10 +1319,10 @@
"copied": "已复制到剪贴板",
"copyFailed": "复制失败",
"actions": {
"sendToAllSessions": "发送到全部会话"
"sendToAllSessions": "发送到全部服务器"
},
"notifications": {
"sentToAllSessions": "指令已发送到 {count} 个会话。",
"sentToAllSessions": "指令已发送到 {count} 台服务器。",
"noActiveSshSessions": "没有活动的 SSH 会话可发送指令。"
}
},
@@ -1360,10 +1360,10 @@
"clickToEditTag": "点击编辑标签名称"
},
"actions": {
"sendToAllSessions": "发送到全部会话"
"sendToAllSessions": "发送到全部服务器"
},
"notifications": {
"sentToAllSessions": "指令已发送到 {count} 个会话。",
"sentToAllSessions": "指令已发送到 {count} 台服务器。",
"noActiveSshSessions": "没有活动的 SSH 会话可发送指令。"
}
},
@@ -15,6 +15,8 @@ import { createStatusMonitorManager, type StatusMonitorDependencies } from '../.
import { createDockerManager, type DockerManagerDependencies } from '../../../composables/useDockerManager';
import { registerSshSuspendHandlers } from './sshSuspendActions';
const SESSION_ORDER_STORAGE_KEY = 'sessionOrder';
// --- 辅助函数 (特定于此模块的 actions) ---
const findConnectionInfo = (connectionId: number | string, connectionsStore: ReturnType<typeof useConnectionsStore>): ConnectionInfo | undefined => {
@@ -33,6 +35,75 @@ const getNextTerminalIndex = (connectionId: string): number => {
return maxTerminalIndex + 1;
};
const getOrderedSessionIds = (): string[] => {
const savedOrderStr = localStorage.getItem(SESSION_ORDER_STORAGE_KEY);
let savedOrder: string[] = [];
if (savedOrderStr) {
try {
savedOrder = JSON.parse(savedOrderStr);
} catch (error) {
console.error('[SessionActions] 解析 sessionOrder 失败,回退到创建顺序。', error);
savedOrder = [];
}
}
const sessionList = Array.from(sessions.value.values());
if (savedOrder.length === 0) {
return sessionList
.sort((a, b) => a.createdAt - b.createdAt)
.map((session) => session.sessionId);
}
return sessionList
.sort((a, b) => {
const indexA = savedOrder.indexOf(a.sessionId);
const indexB = savedOrder.indexOf(b.sessionId);
if (indexA === -1 && indexB === -1) return a.createdAt - b.createdAt;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
})
.map((session) => session.sessionId);
};
const saveOrderedSessionIds = (sessionIds: string[]) => {
localStorage.setItem(SESSION_ORDER_STORAGE_KEY, JSON.stringify(sessionIds));
};
const insertSessionIdIntoOrder = (sessionId: string, connectionId: string) => {
const orderedSessionIds = getOrderedSessionIds();
const nextOrder = [...orderedSessionIds];
if (nextOrder.includes(sessionId)) {
return;
}
let insertionIndex = nextOrder.length;
for (let index = nextOrder.length - 1; index >= 0; index -= 1) {
const orderedSession = sessions.value.get(nextOrder[index]);
if (orderedSession?.connectionId === connectionId) {
insertionIndex = index + 1;
break;
}
}
nextOrder.splice(insertionIndex, 0, sessionId);
saveOrderedSessionIds(nextOrder);
};
const replaceSessionIdInOrder = (previousSessionId: string, nextSessionId: string) => {
const orderedSessionIds = getOrderedSessionIds();
const updatedOrder = orderedSessionIds.map((sessionId) => (sessionId === previousSessionId ? nextSessionId : sessionId));
saveOrderedSessionIds(Array.from(new Set(updatedOrder)));
};
const removeSessionIdFromOrder = (sessionId: string) => {
const orderedSessionIds = getOrderedSessionIds().filter((orderedSessionId) => orderedSessionId !== sessionId);
saveOrderedSessionIds(orderedSessionIds);
};
// --- Actions ---
export const openNewSession = (
connectionOrId: ConnectionInfo | number | string,
@@ -128,6 +199,7 @@ export const openNewSession = (
const newSessionsMap = new Map(sessions.value);
newSessionsMap.set(newSessionId, newSession);
sessions.value = newSessionsMap;
insertSessionIdIntoOrder(newSessionId, dbConnId);
activeSessionId.value = newSessionId;
console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId} (terminal #${terminalIndex})`);
@@ -157,6 +229,7 @@ export const openNewSession = (
currentSessions.set(backendSID, sessionToUpdate);
sessions.value = currentSessions;
replaceSessionIdInOrder(originalFrontendSessionIdForHandler, backendSID);
if (activeSessionId.value === originalFrontendSessionIdForHandler) {
activeSessionId.value = backendSID;
@@ -253,6 +326,7 @@ export const closeSession = (sessionId: string) => {
const newSessionsMap = new Map(sessions.value);
newSessionsMap.delete(sessionId);
sessions.value = newSessionsMap;
removeSessionIdFromOrder(sessionId);
console.log(`[SessionActions] 已从 Map 中移除会话: ${sessionId}`);
// 3. 切换活动标签页
@@ -333,5 +407,6 @@ export const cleanupAllSessions = () => {
newSessionsMap.clear();
sessions.value = newSessionsMap;
}
saveOrderedSessionIds([]);
activeSessionId.value = null;
};
@@ -0,0 +1,30 @@
import type { ConnectionInfo } from '../stores/connections.store';
import type { SessionState } from '../stores/session/types';
export const getUniqueConnectedSshSessions = (
sessionMap: Map<string, SessionState>,
connections: ConnectionInfo[],
): SessionState[] => {
const selectedSessions: SessionState[] = [];
const seenConnectionIds = new Set<string>();
for (const session of sessionMap.values()) {
if (session.wsManager.connectionStatus.value !== 'connected') {
continue;
}
const connectionInfo = connections.find((connection) => connection.id === Number(session.connectionId));
if (!connectionInfo || connectionInfo.type !== 'SSH') {
continue;
}
if (seenConnectionIds.has(session.connectionId)) {
continue;
}
seenConnectionIds.add(session.connectionId);
selectedSessions.push(session);
}
return selectedSessions;
};
@@ -93,6 +93,7 @@ import { useSessionStore } from '../stores/session.store';
import type { SessionState } from '../stores/session/types';
import { useConnectionsStore } from '../stores/connections.store';
import { useConfirmDialog } from '../composables/useConfirmDialog';
import { getUniqueConnectedSshSessions } from '../utils/sessionSelection';
const commandHistoryStore = useCommandHistoryStore();
const { showConfirmDialog } = useConfirmDialog();
@@ -302,13 +303,7 @@ const closeCommandHistoryContextMenu = () => {
const handleCommandHistoryMenuAction = (action: 'sendToAllSessions', entry: CommandHistoryEntryFE) => {
closeCommandHistoryContextMenu();
if (action === 'sendToAllSessions') {
const activeSshSessions = Array.from(sessionStore.sessions.values()).filter(
(s: SessionState) => {
if (s.wsManager.connectionStatus.value !== 'connected') return false;
const connInfo = connectionsStore.connections.find(c => c.id === Number(s.connectionId));
return connInfo?.type === 'SSH';
}
);
const activeSshSessions = getUniqueConnectedSshSessions(sessionStore.sessions, connectionsStore.connections);
if (activeSshSessions.length > 0) {
activeSshSessions.forEach((session: SessionState) => {
+176 -49
View File
@@ -84,6 +84,7 @@ const getInitialSelectedScope = (): ScopeId => {
const selectedScope = ref<ScopeId>(getInitialSelectedScope());
const activeTypeFilter = ref<ConnectionTypeFilter>((localStorage.getItem(LS_TYPE_FILTER_KEY) as ConnectionTypeFilter) || 'ALL');
const searchQuery = ref('');
const treeSearchQuery = ref('');
const tagsSectionExpanded = ref(true);
const showAddEditConnectionForm = ref(false);
@@ -113,6 +114,7 @@ const TREE_EXPANDED_STORAGE_KEY = 'connections_view_tree_expanded';
const tagPathSeparatorRegex = /\s*(?:\/|>|\\)\s*/;
const normalizedSearchQuery = computed(() => searchQuery.value.toLowerCase().trim());
const normalizedTreeSearchQuery = computed(() => treeSearchQuery.value.toLowerCase().trim());
const loadInitialExpandedTreeState = (): Record<string, boolean> => {
try {
@@ -285,19 +287,69 @@ const tagTreeNodes = computed<TagTreeNode[]>(() => {
return buildNodes(rootChildren, 0);
});
const filteredTagTreeNodes = computed<TagTreeNode[]>(() => {
const query = normalizedTreeSearchQuery.value;
if (!query) {
return tagTreeNodes.value;
}
const filterNodes = (nodes: TagTreeNode[]): TagTreeNode[] => {
return nodes.flatMap((node) => {
const filteredChildren = filterNodes(node.children);
const selfMatches = node.label.toLowerCase().includes(query) || node.fullLabel.toLowerCase().includes(query);
if (!selfMatches && filteredChildren.length === 0) {
return [];
}
return [{
...node,
children: filteredChildren,
}];
});
};
return filterNodes(tagTreeNodes.value);
});
const matchingTreeNodeIds = computed<Set<ScopeId>>(() => {
const matches = new Set<ScopeId>();
const query = normalizedTreeSearchQuery.value;
if (!query) {
return matches;
}
const walkNodes = (nodes: TagTreeNode[]) => {
nodes.forEach((node) => {
if (node.label.toLowerCase().includes(query) || node.fullLabel.toLowerCase().includes(query)) {
matches.add(node.id);
}
if (node.children.length > 0) {
walkNodes(node.children);
}
});
};
walkNodes(tagTreeNodes.value);
return matches;
});
const visibleTagTreeNodes = computed<TagTreeNode[]>(() => {
const rows: TagTreeNode[] = [];
const isSearchMode = Boolean(normalizedTreeSearchQuery.value);
const appendVisibleNodes = (nodes: TagTreeNode[]) => {
nodes.forEach((node) => {
rows.push(node);
if (node.expandable && (expandedTreeNodes.value[node.id] ?? true)) {
if (node.expandable && (isSearchMode || (expandedTreeNodes.value[node.id] ?? true))) {
appendVisibleNodes(node.children);
}
});
};
appendVisibleNodes(tagTreeNodes.value);
appendVisibleNodes(filteredTagTreeNodes.value);
return rows;
});
@@ -318,6 +370,7 @@ const expandableTreeNodeIds = computed<ScopeId[]>(() => {
});
const hasExpandableTreeNodes = computed(() => expandableTreeNodeIds.value.length > 0);
const hasTreeSearchResults = computed(() => visibleTagTreeNodes.value.length > 0);
const primaryScopeNodes = computed<ScopeNode[]>(() => {
return [
@@ -479,6 +532,34 @@ const getScopeNodeClass = (nodeId: ScopeId) => {
return 'text-text-secondary border-transparent hover:bg-header hover:text-foreground';
};
const getTreeNodeRowClass = (node: TagTreeNode) => {
if (selectedScope.value === node.id) {
return 'bg-primary/15 text-foreground border-primary/30 shadow-sm';
}
if (matchingTreeNodeIds.value.has(node.id)) {
return 'border-emerald-400/30 bg-emerald-500/8 text-emerald-100 shadow-sm';
}
return 'text-text-secondary border-transparent hover:bg-header hover:text-foreground';
};
const getTreeCountClass = (node: ScopeNode) => {
if (selectedScope.value === node.id) {
return 'border-primary/30 bg-primary/20 text-foreground';
}
if (matchingTreeNodeIds.value.has(node.id)) {
return 'border-emerald-400/25 bg-emerald-500/18 text-emerald-100';
}
if (node.count > 0) {
return 'border-emerald-500/15 bg-emerald-500/10 text-emerald-200';
}
return 'border-current/15 bg-black/10 text-text-secondary';
};
const getTypeBadgeClass = (type: ConnectionInfo['type']) => {
if (type === 'SSH') {
return 'bg-emerald-500/12 text-emerald-300 border-emerald-400/25';
@@ -625,6 +706,10 @@ const resetScopeSelection = () => {
selectScope('all');
};
const clearTreeSearch = () => {
treeSearchQuery.value = '';
};
const connectTo = (connection: ConnectionInfo) => {
sessionStore.handleConnectRequest(connection);
};
@@ -924,18 +1009,58 @@ onBeforeUnmount(() => {
<div class="grid grid-cols-1 xl:grid-cols-[280px_minmax(0,1fr)] gap-4">
<aside class="bg-card text-card-foreground border border-border rounded-2xl overflow-hidden min-h-[720px]">
<div class="px-4 py-4 border-b border-border/60 bg-header/40">
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-sm font-semibold tracking-wide text-foreground">{{ t('connections.scopeTitle', '浏览范围') }}</h2>
<p class="mt-1 text-xs text-text-secondary">{{ t('connections.scopeDesc', '按标签和分组快速切换连接范围') }}</p>
<div class="px-4 pt-4 pb-3 border-b border-border/60 bg-gradient-to-b from-header/70 via-header/35 to-background/70">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="w-9 h-9 rounded-xl border border-emerald-400/20 bg-emerald-500/10 text-emerald-200 inline-flex items-center justify-center">
<i class="fas fa-folder-tree text-sm"></i>
</div>
<div class="min-w-0">
<h2 class="text-sm font-semibold tracking-[0.18em] uppercase text-foreground">{{ t('connections.scopeTitle', '浏览范围') }}</h2>
<p class="mt-0.5 text-xs text-text-secondary truncate">{{ t('connections.scopeDesc', '按标签和分组快速切换连接范围') }}</p>
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<span class="px-2.5 py-1 rounded-full border border-emerald-400/20 bg-emerald-500/10 text-[11px] font-medium text-emerald-100">
{{ visibleTagTreeNodes.length }} {{ t('connections.table.tags', '标签') }}
</span>
<button
@click="tagsSectionExpanded = !tagsSectionExpanded"
class="w-8 h-8 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors"
:title="tagsSectionExpanded ? t('common.collapse', '收起') : t('common.expand', '展开')"
>
<i :class="['fas', tagsSectionExpanded ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
</button>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<div class="relative flex-1 min-w-0">
<i class="fas fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary text-xs"></i>
<input
v-model="treeSearchQuery"
type="text"
:placeholder="t('connections.scopeTreeSearch', '搜索标签树...')"
class="w-full h-9 pl-9 pr-9 rounded-xl border border-border/60 bg-background/95 text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary transition"
/>
<button
v-if="treeSearchQuery"
@click="clearTreeSearch"
class="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 rounded-md text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center justify-center"
:title="t('common.clear', '清空')"
>
<i class="fas fa-xmark text-xs"></i>
</button>
</div>
<button
@click="tagsSectionExpanded = !tagsSectionExpanded"
class="w-8 h-8 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors"
:title="tagsSectionExpanded ? t('common.collapse', '收起') : t('common.expand', '展开')"
@click="resetScopeSelection"
:disabled="selectedScope === 'all'"
class="h-9 px-3 rounded-xl border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<i :class="['fas', tagsSectionExpanded ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
<i class="fas fa-crosshairs"></i>
<span>{{ t('common.reset', '重置') }}</span>
</button>
</div>
</div>
@@ -954,6 +1079,7 @@ onBeforeUnmount(() => {
'w-full flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150',
getScopeNodeClass(node.id)
]"
:class="getTreeCountClass(node)"
>
<span class="flex items-center gap-2 min-w-0">
<i :class="['fas', node.id === 'all' ? 'fa-layer-group' : 'fa-tag', 'w-4 text-center']"></i>
@@ -968,7 +1094,7 @@ onBeforeUnmount(() => {
<section>
<div class="px-2 mb-2 flex items-center justify-between gap-3 text-xs font-semibold uppercase tracking-[0.18em] text-text-secondary/80">
<span>{{ t('connections.table.tags', '标签') }}</span>
<span>{{ t('connections.table.tags', '标签资源管理器') }}</span>
<span class="text-[11px] tracking-normal normal-case text-text-secondary">{{ visibleTagTreeNodes.length }}</span>
</div>
@@ -990,50 +1116,51 @@ onBeforeUnmount(() => {
<i class="fas fa-square-minus"></i>
<span>{{ t('common.collapseAll', '收起全部') }}</span>
</button>
<button
@click="resetScopeSelection"
:disabled="selectedScope === 'all'"
class="h-8 px-2.5 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fas fa-rotate-left"></i>
<span>{{ t('common.reset', '重置范围') }}</span>
</button>
</div>
<div class="px-2 flex items-center justify-between gap-3 text-[11px] text-text-secondary">
<span>{{ t('connections.scopeHintCompact', '树节点按标签路径自动分层') }}</span>
<span>{{ selectedScopeTitle }}</span>
<span class="truncate text-right">{{ normalizedTreeSearchQuery ? t('connections.scopeSearchMode', '命中路径已自动展开') : selectedScopeTitle }}</span>
</div>
<div class="space-y-1 max-h-[520px] overflow-y-auto pr-1">
<div
v-for="node in visibleTagTreeNodes"
:key="node.id"
:class="[
'w-full flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150',
getScopeNodeClass(node.id),
node.count === 0 ? 'opacity-55' : ''
]"
:style="{ paddingLeft: `${0.75 + node.level * 1.05}rem` }"
>
<button
class="flex items-center gap-2 min-w-0 flex-1"
@click="handleTreeNodeSelect(node)"
>
<i
v-if="node.expandable"
:class="[
'fas w-4 text-center transition-transform duration-150',
(expandedTreeNodes[node.id] ?? true) ? 'fa-chevron-down' : 'fa-chevron-right'
]"
></i>
<i v-else class="fas fa-circle text-[8px] w-4 text-center opacity-60"></i>
<span class="truncate" :title="node.fullLabel">{{ node.label }}</span>
</button>
<span class="px-2 py-0.5 rounded-full text-xs border border-current/15 bg-black/10 flex-shrink-0">
{{ node.count }}
</span>
<div v-if="normalizedTreeSearchQuery && !hasTreeSearchResults" class="mx-2 rounded-xl border border-dashed border-border/70 bg-background/70 px-3 py-4 text-xs text-text-secondary text-center">
{{ t('connections.scopeTreeNoMatch', '没有匹配的树节点') }}
</div>
<div v-else class="space-y-1 max-h-[520px] overflow-y-auto pr-1">
<div
v-for="node in visibleTagTreeNodes"
:key="node.id"
:class="[
'w-full flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150',
getTreeNodeRowClass(node),
node.count === 0 ? 'opacity-55' : ''
]"
:style="{ paddingLeft: `${0.75 + node.level * 1.05}rem` }"
>
<button
class="flex items-center gap-2 min-w-0 flex-1"
@click="handleTreeNodeSelect(node)"
>
<i
v-if="node.expandable"
:class="[
'fas w-4 text-center transition-transform duration-150',
(normalizedTreeSearchQuery || (expandedTreeNodes[node.id] ?? true)) ? 'fa-chevron-down' : 'fa-chevron-right'
]"
></i>
<i v-else class="fas fa-circle text-[8px] w-4 text-center opacity-60"></i>
<span class="truncate" :title="node.fullLabel">{{ node.label }}</span>
</button>
<span
:class="[
'px-2 py-0.5 rounded-full text-xs border flex-shrink-0 transition-colors',
getTreeCountClass(node)
]"
>
{{ node.count }}
</span>
</div>
</div>
</div>
</section>
@@ -241,6 +241,7 @@ import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
import { useSessionStore } from '../stores/session.store';
import type { SessionState } from '../stores/session/types';
import { useConnectionsStore } from '../stores/connections.store';
import { getUniqueConnectedSshSessions } from '../utils/sessionSelection';
const quickCommandsStore = useQuickCommandsStore();
const quickCommandTagsStore = useQuickCommandTagsStore();
@@ -763,13 +764,7 @@ const closeQuickCommandContextMenu = () => {
const handleQuickCommandMenuAction = (action: 'sendToAllSessions', command: QuickCommandFE) => {
closeQuickCommandContextMenu();
if (action === 'sendToAllSessions') {
const activeSshSessions = Array.from(sessionStore.sessions.values()).filter(
(s: SessionState) => {
if (s.wsManager.connectionStatus.value !== 'connected') return false;
const connInfo = connectionsStore.connections.find(c => c.id === Number(s.connectionId));
return connInfo?.type === 'SSH';
}
);
const activeSshSessions = getUniqueConnectedSshSessions(sessionStore.sessions, connectionsStore.connections);
if (activeSshSessions.length > 0) {
activeSshSessions.forEach((session: SessionState) => {