feat: 添加标签管理模态框

This commit is contained in:
Baobhan Sith
2025-05-11 11:20:26 +08:00
parent 1eb1efde72
commit 598df938bf
40 changed files with 634 additions and 170 deletions
@@ -679,7 +679,7 @@ const testButtonText = computed(() => {
<div v-else class="flex-1"></div> <!-- This div ensures the main action buttons are pushed to the right when test area is hidden -->
<div class="flex space-x-3"> <!-- Main Actions -->
<button v-if="isEditMode" type="button" @click="handleDeleteConnection" :disabled="isLoading || (formData.type === 'SSH' && testStatus === 'testing')"
class="px-4 py-2 bg-transparent text-red-600 border border-red-500 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
class="px-4 py-2 bg-transparent text-red-600 border border-red-500 rounded-md shadow-sm hover:bg-red-500/10 hover:text-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
{{ t('connections.actions.delete') }}
</button>
<button type="submit" @click="handleSubmit" :disabled="isLoading || (formData.type === 'SSH' && testStatus === 'testing')"
@@ -9,7 +9,7 @@ import { useQuickCommandsStore } from '../stores/quickCommands.store';
import { useCommandHistoryStore } from '../stores/commandHistory.store';
import QuickCommandsModal from './QuickCommandsModal.vue'; // +++ Import the modal component +++
import SuspendedSshSessionsModal from './SuspendedSshSessionsModal.vue'; // +++ Import the new modal +++
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ 新增导入 +++
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
// Disable attribute inheritance as this component has multiple root nodes (div + modal)
defineOptions({ inheritAttrs: false });
@@ -10,7 +10,7 @@ import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; // +++
import { useSessionStore } from '../stores/session.store'; // +++ 导入会话 Store +++
import { useSettingsStore } from '../stores/settings.store'; // +++ 导入设置 Store +++
import { storeToRefs } from 'pinia'; // +++ 导入 storeToRefs +++
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ 新增导入 +++
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
const { t } = useI18n();
const emitWorkspaceEvent = useWorkspaceEventEmitter(); // +++ 获取事件发射器 +++
@@ -120,13 +120,11 @@ const currentTabSaveError = computed(() => activeTab.value?.saveError ?? null);
const currentTabLanguage = computed(() => activeTab.value?.language ?? 'plaintext');
const currentTabFilePath = computed(() => activeTab.value?.filePath ?? '');
const currentTabIsModified = computed(() => activeTab.value?.isModified ?? false); // 用于显示修改状态
// +++ 新增:计算当前选择的编码 +++
const currentSelectedEncoding = computed(() => activeTab.value?.selectedEncoding ?? 'utf-8');
// +++ 新增:计算当前活动标签的会话名称 +++
const currentTabSessionName = computed(() => {
const sessionId = activeTab.value?.sessionId;
if (!sessionId) return null;
return sessionStore.sessions.get(sessionId)?.connectionName ?? null; // 修正:使用 connectionName
return sessionStore.sessions.get(sessionId)?.connectionName ?? null;
});
// Watch for changes in the selected encoding to update width
@@ -134,7 +132,7 @@ watch(currentSelectedEncoding, () => {
updateSelectWidth();
});
// +++ 新增:编码选项 +++
// +++ 编码选项 +++
// 注意:这里的 value 需要与 iconv-lite 支持的标签匹配 (后端使用)
// 扩展编码列表以包含更多常用选项
const encodingOptions = ref([
@@ -194,7 +192,7 @@ const handleSaveRequest = () => {
}
};
// +++ 新增:处理编码更改事件 +++
// +++ 处理编码更改事件 +++
const handleEncodingChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
const newEncoding = target.value;
@@ -243,7 +241,7 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
});
// +++ 新增:处理键盘事件以切换标签 +++
// +++ 处理键盘事件以切换标签 +++
const handleKeyDown = (event: KeyboardEvent) => {
// 检查是否在编辑器内部或其容器内触发(避免全局冲突)
// 这里简化处理,假设只要此组件挂载就监听,更精确的判断可能需要检查 event.target
@@ -201,9 +201,9 @@ const currentTabSaveError = computed(() => activeTab.value?.saveError ?? null);
const currentTabLanguage = computed(() => activeTab.value?.language ?? 'plaintext');
const currentTabFilePath = computed(() => activeTab.value?.filePath ?? '');
const currentTabIsModified = computed(() => activeTab.value?.isModified ?? false);
// +++ 新增:计算当前选择的编码 (与 Container 逻辑一致) +++
// +++ 计算当前选择的编码 (与 Container 逻辑一致) +++
const currentSelectedEncoding = computed(() => activeTab.value?.selectedEncoding ?? 'utf-8');
// +++ 新增:计算当前活动标签的会话名称 (与 Container 逻辑一致) +++
// +++ 计算当前活动标签的会话名称 (与 Container 逻辑一致) +++
const currentTabSessionName = computed(() => {
const sessionId = activeTab.value?.sessionId;
if (!sessionId) return null;
@@ -213,7 +213,7 @@ const currentTabSessionName = computed(() => {
// --- 事件处理 (根据模式调用不同 action) ---
// +++ 新增:编码选项 (copied from FileEditorContainer) +++
// +++ 编码选项 (copied from FileEditorContainer) +++
// 注意:这里的 value 需要与 iconv-lite 支持的标签匹配 (后端使用)
const encodingOptions = ref([
// Unicode
@@ -352,7 +352,7 @@ const handleCloseLeftTabs = (targetTabId: string) => {
}
};
// +++ 新增:处理编码更改事件 +++
// +++ 处理编码更改事件 +++
const handleEncodingChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
const newEncoding = target.value;
@@ -488,7 +488,7 @@ onBeforeUnmount(() => {
<span v-if="currentTabIsModified" class="modified-indicator">*</span>
</span>
<div class="editor-actions">
<!-- +++ 新增编码选择下拉菜单 +++ -->
<!-- +++ 编码选择下拉菜单 +++ -->
<div class="encoding-select-wrapper" v-if="activeTab && !currentTabIsLoading">
<select
ref="encodingSelectRef"
@@ -725,7 +725,7 @@ onBeforeUnmount(() => {
}
*/
/* +++ 新增:编码选择器样式 (copied from FileEditorContainer) +++ */
/* +++ 编码选择器样式 (copied from FileEditorContainer) +++ */
.encoding-select-wrapper {
display: inline-block; /* 让 wrapper 包裹内容 */
vertical-align: middle; /* 垂直居中对齐 */
@@ -15,7 +15,7 @@ import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileMa
import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation';
import FileUploadPopup from './FileUploadPopup.vue';
import FileManagerContextMenu from './FileManagerContextMenu.vue';
import FileManagerActionModal from './FileManagerActionModal.vue'; // +++ +++
import FileManagerActionModal from './FileManagerActionModal.vue';
import type { FileListItem } from '../types/sftp.types';
import type { WebSocketMessage } from '../types/websocket.types';
@@ -29,7 +29,7 @@ const props = defineProps({
type: String,
required: true,
},
// ID
// ID
instanceId: {
type: String,
required: true,
@@ -117,28 +117,28 @@ const sortDirection = ref<'asc' | 'desc'>('asc');
// const initialLoadDone = ref(false); // SFTP Manager
// const isFetchingInitialPath = ref(false); // isLoading !initialLoadDone
const isEditingPath = ref(false);
const searchQuery = ref(''); // ref
const isSearchActive = ref(false); //
const searchInputRef = ref<HTMLInputElement | null>(null); // ref
const searchQuery = ref(''); // ref
const isSearchActive = ref(false); //
const searchInputRef = ref<HTMLInputElement | null>(null); // ref
const pathInputRef = ref<HTMLInputElement | null>(null);
const editablePath = ref('');
const fileListContainerRef = ref<HTMLDivElement | null>(null); //
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ +++
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ +++
// const scrollIntervalId = ref<number | null>(null); // useFileManagerDragAndDrop
// +++ +++
// +++ +++
const isActionModalVisible = ref(false);
const currentActionType = ref<'delete' | 'rename' | 'chmod' | 'newFile' | 'newFolder' | null>(null);
const actionItem = ref<FileListItem | null>(null); // For single item operations
const actionItems = ref<FileListItem[]>([]); // For multi-item operations (e.g., delete)
const actionInitialValue = ref(''); // For pre-filling input in modal
// +++ +++
// +++ +++
const clipboardState = ref<ClipboardState>({ hasContent: false });
const clipboardSourcePaths = ref<string[]>([]); //
const clipboardSourceBaseDir = ref<string>(''); //
const rowSizeMultiplier = ref(1.0); // , store
const rowSizeMultiplier = ref(1.0); // , store
// --- ( useFileManagerKeyboardNavigation) ---
// const selectedIndex = ref<number>(-1);
@@ -416,7 +416,7 @@ const handleNewFileContextMenuClick = () => {
openActionModal('newFile');
};
// +++ +++
// +++ +++
const handleCopy = () => {
if (!currentSftpManager.value || selectedItems.value.size === 0) return;
const manager = currentSftpManager.value;
@@ -526,7 +526,7 @@ const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileList
};
// +++ +++
// +++ +++
const triggerDownloadDirectory = (item: FileListItem) => {
if (!props.wsDeps.isConnected.value) {
alert(t('fileManager.errors.notConnected'));
@@ -659,7 +659,7 @@ const {
// --- (使 Composable) ---
const {
// isDraggingOver, // 使
showExternalDropOverlay, //
showExternalDropOverlay, //
dragOverTarget, // ()
// draggedItem, // FileManager 使
// --- ---
@@ -667,7 +667,7 @@ const {
handleDragOver, // dragover ()
handleDragLeave,
handleDrop, // drop ()
handleOverlayDrop, // drop
handleOverlayDrop, // drop
handleDragStart,
handleDragEnd,
handleDragOverRow,
@@ -899,7 +899,7 @@ watch(() => focusSwitcherStore.activateFileManagerSearchTrigger, (newValue, oldV
}, { immediate: false }); // immediate: false 0
// --- sessionId prop ---
// --- sessionId prop ---
watch(() => props.sessionId, (newSessionId, oldSessionId) => {
if (newSessionId && newSessionId !== oldSessionId) {
console.log(`[FileManager ${newSessionId}-${props.instanceId}] Session ID changed from ${oldSessionId} to ${newSessionId}. Re-initializing.`);
@@ -981,7 +981,7 @@ onBeforeUnmount(() => {
sessionStore.removeSftpManager(props.sessionId, props.instanceId);
});
// +++ +++
// +++ +++
watch(showExternalDropOverlay, (isVisible) => {
if (isVisible) {
nextTick(() => { // refs scrollHeight
@@ -1112,7 +1112,7 @@ const cancelSearch = () => {
isSearchActive.value = false;
};
// --- CD ---
// --- CD ---
const sendCdCommandToTerminal = () => {
if (!currentSftpManager.value || !props.wsDeps.isConnected.value) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot send CD command: SFTP manager not ready or not connected.`);
@@ -1160,7 +1160,7 @@ const sendCdCommandToTerminal = () => {
};
// --- ---
// --- ---
const openPopupEditor = () => {
if (!props.sessionId) {
console.error('[FileManager] Cannot open popup editor: Missing session ID.');
@@ -1189,7 +1189,7 @@ const handleWheel = (event: WheelEvent) => {
}
};
// +++ +++
// +++ +++
const focusSearchInput = (): boolean => {
//
if (props.sessionId !== sessionStore.activeSessionId) {
@@ -1219,7 +1219,7 @@ const focusSearchInput = (): boolean => {
};
defineExpose({ focusSearchInput, startPathEdit });
// --- ---
// --- ---
const handleOpenEditorClick = () => {
if (!props.sessionId) {
console.error(`[FileManager ${props.instanceId}] Cannot open editor: Missing session ID.`);
@@ -1240,7 +1240,7 @@ const handleOpenEditorClick = () => {
<div class="flex items-center gap-2 flex-shrink"> <!-- Added gap-2 -->
<!-- Path Actions -->
<div class="flex items-center flex-shrink-0"> <!-- Removed mr-auto -->
<!-- 新增CD 到终端按钮 -->
<!-- CD 到终端按钮 -->
<button
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
@click.stop="sendCdCommandToTerminal"
@@ -1329,7 +1329,7 @@ const handleOpenEditorClick = () => {
<!-- Main Actions Bar -->
<div class="flex items-center gap-2 flex-shrink-0">
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple class="hidden" />
<!-- 新增打开编辑器按钮 -->
<!-- 打开编辑器按钮 -->
<button
v-if="showPopupFileEditorBoolean"
@click="openPopupEditor"
@@ -1387,7 +1387,7 @@ const handleOpenEditorClick = () => {
tabindex="0"
:style="{ '--row-size-multiplier': rowSizeMultiplier }"
>
<!-- 新增外部文件拖拽蒙版 -->
<!-- 外部文件拖拽蒙版 -->
<div
v-if="showExternalDropOverlay"
ref="dropOverlayRef"
@@ -0,0 +1,251 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store';
import { useTagsStore, type TagInfo } from '../stores/tags.store';
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
interface Props {
tagInfo: TagInfo | null; // TagInfo
visible: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits(['update:visible', 'saved', 'tag-deleted']);
const { t } = useI18n();
const connectionsStore = useConnectionsStore();
const tagsStore = useTagsStore(); // actions
const uiNotificationsStore = useUiNotificationsStore();
const { connections: allConnections, isLoading: connectionsLoading } = storeToRefs(connectionsStore);
const modalSearchTerm = ref('');
// 使 Set ID便
const selectedConnectionIds = ref<Set<number>>(new Set());
const internalVisible = ref(props.visible);
// props.visible internalVisible
watch(() => props.visible, (newVisibleValue) => {
internalVisible.value = newVisibleValue;
if (!newVisibleValue) {
//
modalSearchTerm.value = '';
}
});
// internalVisible emit update:visible
watch(internalVisible, (newVal) => {
if (newVal !== props.visible) { //
emit('update:visible', newVal);
}
});
// tagInfo
watch(() => [internalVisible.value, props.tagInfo], ([isVisible, currentTagInfoUntyped]) => {
const currentTagInfo = currentTagInfoUntyped as TagInfo | null; //
if (isVisible && currentTagInfo && typeof currentTagInfo === 'object' && currentTagInfo !== null && 'id' in currentTagInfo) {
selectedConnectionIds.value.clear();
allConnections.value.forEach(conn => {
if (conn.tag_ids?.includes(currentTagInfo.id)) {
selectedConnectionIds.value.add(conn.id);
}
});
}
}, { immediate: true, deep: true });
const filteredConnectionsInModal = computed(() => {
if (!modalSearchTerm.value) {
return allConnections.value.slice().sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
}
const lowerSearchTerm = modalSearchTerm.value.toLowerCase();
return allConnections.value
.filter(conn =>
(conn.name && conn.name.toLowerCase().includes(lowerSearchTerm)) ||
conn.host.toLowerCase().includes(lowerSearchTerm)
)
.sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
});
const isConnectionSelected = (connectionId: number): boolean => {
return selectedConnectionIds.value.has(connectionId);
};
const toggleConnectionSelection = (connectionId: number) => {
if (selectedConnectionIds.value.has(connectionId)) {
selectedConnectionIds.value.delete(connectionId);
} else {
selectedConnectionIds.value.add(connectionId);
}
};
const handleSelectAll = () => {
filteredConnectionsInModal.value.forEach(conn => selectedConnectionIds.value.add(conn.id));
};
const handleDeselectAll = () => {
filteredConnectionsInModal.value.forEach(conn => selectedConnectionIds.value.delete(conn.id));
};
const handleInvertSelection = () => {
filteredConnectionsInModal.value.forEach(conn => {
if (selectedConnectionIds.value.has(conn.id)) {
selectedConnectionIds.value.delete(conn.id);
} else {
selectedConnectionIds.value.add(conn.id);
}
});
};
const handleSave = async () => {
if (!props.tagInfo) return;
const currentTagId = props.tagInfo.id;
const newConnectionIdArray = Array.from(selectedConnectionIds.value);
// store action
// tagsStore updateTagConnections action
const success = await tagsStore.updateTagConnections(currentTagId, newConnectionIdArray);
if (success) {
uiNotificationsStore.addNotification({ message: t('workspaceConnectionList.manageTags.saveSuccess'), type: 'success' });
emit('saved'); //
emit('update:visible', false);
} else {
uiNotificationsStore.addNotification({ message: t('workspaceConnectionList.manageTags.saveFailed'), type: 'error' });
}
};
const handleCancel = () => {
emit('update:visible', false);
};
const handleDeleteTag = async () => {
if (!props.tagInfo) return;
const tagName = props.tagInfo.name;
if (confirm(t('tags.prompts.confirmDelete', { name: tagName }))) {
const success = await tagsStore.deleteTag(props.tagInfo.id);
if (success) {
uiNotificationsStore.addNotification({ message: t('tags.deleteSuccess', { name: tagName }), type: 'success' }); //
emit('tag-deleted'); //
emit('update:visible', false); //
} else {
uiNotificationsStore.addNotification({ message: t('tags.deleteFailed', { name: tagName, error: tagsStore.error || 'Unknown error' }), type: 'error' }); //
}
}
};
onMounted(() => {
if (connectionsStore.connections.length === 0) {
connectionsStore.fetchConnections();
}
});
</script>
<template>
<div
v-if="internalVisible"
class="fixed inset-0 bg-overlay flex justify-center items-center z-50 p-4"
@click.self="handleCancel"
>
<div class="bg-background text-foreground p-6 rounded-lg shadow-xl border border-border w-full max-w-2xl max-h-[90vh] flex flex-col">
<!-- Header -->
<h3 class="text-xl font-semibold text-center mb-6 flex-shrink-0">
{{ t('workspaceConnectionList.manageTags.title') }} - {{ props.tagInfo?.name }}
</h3>
<!-- Controls & List Container -->
<div class="flex-grow overflow-y-hidden flex flex-col">
<!-- Controls -->
<div class="p-4 border-b border-border/50 flex items-center gap-2 flex-shrink-0">
<input
type="text"
v-model="modalSearchTerm"
:placeholder="t('workspaceConnectionList.manageTags.searchPlaceholder')"
class="flex-grow min-w-0 px-3 py-2 border border-border rounded-md shadow-sm bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm"
/>
<button
@click="handleSelectAll"
class="px-4 py-2 text-sm bg-transparent text-text-secondary border border-border rounded-md shadow-sm hover:bg-border hover:text-foreground focus:outline-none transition duration-150 ease-in-out"
>
{{ t('workspaceConnectionList.manageTags.selectAll') }}
</button>
<button
@click="handleDeselectAll"
class="px-4 py-2 text-sm bg-transparent text-text-secondary border border-border rounded-md shadow-sm hover:bg-border hover:text-foreground focus:outline-none transition duration-150 ease-in-out"
>
{{ t('workspaceConnectionList.manageTags.deselectAll') }}
</button>
<button
@click="handleInvertSelection"
class="px-4 py-2 text-sm bg-transparent text-text-secondary border border-border rounded-md shadow-sm hover:bg-border hover:text-foreground focus:outline-none transition duration-150 ease-in-out"
>
{{ t('workspaceConnectionList.manageTags.invertSelection') }}
</button>
</div>
<!-- Connection List -->
<div class="flex-grow overflow-y-auto p-4 pr-2"> <!-- Removed space-y-2 from here -->
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30"> <!-- New wrapper div -->
<div v-if="connectionsLoading" class="flex items-center justify-center h-full text-text-secondary">
<i class="fas fa-spinner fa-spin mr-2"></i> {{ t('common.loading') }}
</div>
<ul v-else-if="filteredConnectionsInModal.length > 0" class="space-y-1">
<li
v-for="conn in filteredConnectionsInModal"
:key="conn.id"
class="flex items-center p-2.5 rounded-md hover:bg-primary/10 cursor-pointer transition-colors duration-150"
:class="{'bg-primary/20': isConnectionSelected(conn.id)}"
@click="toggleConnectionSelection(conn.id)"
>
<input
type="checkbox"
:checked="isConnectionSelected(conn.id)"
class="mr-3 h-4 w-4 rounded border-border text-primary focus:ring-primary focus:ring-offset-0"
@change.stop="toggleConnectionSelection(conn.id)"
@click.stop
/>
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : (conn.type === 'VNC' ? 'fa-plug' : 'fa-server'), 'mr-2.5 w-4 text-center text-text-secondary']"></i>
<span class="text-sm truncate flex-grow" :title="conn.name || conn.host">
{{ conn.name || conn.host }}
</span>
<span class="text-xs text-text-tertiary ml-2">({{ conn.type }})</span>
</li>
</ul>
<div v-else class="flex flex-col items-center justify-center h-full text-text-secondary p-6">
<i class="fas fa-search text-2xl mb-3"></i>
<p>{{ t('workspaceConnectionList.manageTags.noConnectionsFound') }}</p>
</div>
</div> <!-- End of new wrapper div -->
</div>
</div>
<!-- Footer -->
<div class="flex justify-end items-center pt-5 mt-auto flex-shrink-0 space-x-3">
<button
@click="handleDeleteTag"
class="px-4 py-2 bg-transparent text-error border border-error/70 rounded-md shadow-sm hover:bg-error/10 hover:text-error-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-error transition duration-150 ease-in-out"
>
{{ t('common.delete') }}
</button>
<button
@click="handleCancel"
class="px-4 py-2 bg-transparent text-text-secondary border border-border rounded-md shadow-sm hover:bg-border hover:text-foreground focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-150 ease-in-out"
>
{{ t('common.cancel') }}
</button>
<button
@click="handleSave"
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-150 ease-in-out"
>
{{ t('common.save') }}
</button>
</div>
</div>
</div>
</template>
@@ -8,13 +8,13 @@ import { FitAddon } from '@xterm/addon-fit'; // Updated import path
import { WebLinksAddon } from 'xterm-addon-web-links';
import { SearchAddon, type ISearchOptions } from '@xterm/addon-search';
import 'xterm/css/xterm.css';
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ +++
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
// props emits
const props = defineProps<{
sessionId: string; // ID
isActive: boolean; //
isActive: boolean; //
stream?: ReadableStream<string>; // WebSocket ()
options?: object; // xterm
}>();
@@ -39,7 +39,7 @@ const {
currentTerminalFontFamily,
terminalBackgroundImage,
currentTerminalFontSize,
isTerminalBackgroundEnabled // <--
isTerminalBackgroundEnabled
} = storeToRefs(appearanceStore);
// --- Settings Store ---
@@ -69,7 +69,7 @@ const debouncedEmitResize = debounce((term: Terminal) => {
const dimensions = { cols: term.cols, rows: term.rows };
console.log(`[Terminal ${props.sessionId}] Debounced resize emit (from ResizeObserver):`, dimensions);
emitWorkspaceEvent('terminal:resize', { sessionId: props.sessionId, dims: dimensions });
// *** resize ***
// *** resize ***
try {
term.refresh(0, term.rows - 1); // Refresh entire viewport
console.log(`[Terminal ${props.sessionId}] Terminal refreshed after debounced resize.`);
@@ -9,7 +9,7 @@ import TabBarContextMenu from './TabBarContextMenu.vue';
import { useSessionStore } from '../stores/session.store';
import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store';
import { useLayoutStore, type PaneName } from '../stores/layout.store';
import { useWorkspaceEventEmitter, useWorkspaceEventSubscriber } from '../composables/workspaceEvents'; // +++ +++
import { useWorkspaceEventEmitter, useWorkspaceEventSubscriber } from '../composables/workspaceEvents';
import type { SessionTabInfoWithStatus } from '../stores/session/types'; //
@@ -99,14 +99,14 @@ const handlePopupConnect = (connectionId: number) => {
showConnectionListPopup.value = false; //
};
//
//
const handleRequestAddFromPopup = () => {
console.log('[TabBar] Received request-add-connection from popup component.');
showConnectionListPopup.value = false; //
emitWorkspaceEvent('connection:requestAdd'); //
};
//
//
const handleRequestEditFromPopup = (connection: ConnectionInfo) => { // WorkspaceConnectionList
console.log('[TabBar] Received request-edit-connection from popup component for connection:', connection);
showConnectionListPopup.value = false; //
@@ -180,7 +180,7 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n
console.warn(`[TabBar] 'mark-for-suspend' action called with invalid targetId:`, targetId);
}
break;
case 'unmark-for-suspend': // +++ case +++
case 'unmark-for-suspend':
if (typeof targetId === 'string') {
console.log(`[TabBar] Context menu action 'unmark-for-suspend' requested for session ID: ${targetId}`);
sessionStore.requestUnmarkSshSuspend(targetId);
@@ -250,7 +250,7 @@ const contextMenuItems = computed(() => {
});
//
//
const openLayoutConfigurator = () => {
console.log('[TabBar] Emitting open-layout-configurator event');
emitWorkspaceEvent('ui:openLayoutConfigurator'); //
@@ -328,7 +328,7 @@ const handleDragStart = (event: DragEvent) => {
}
};
//
//
let touchTimeout: number | null = null;
const touchDuration = 800; //
let touchedSessionId: string | null = null;
@@ -9,8 +9,9 @@ import { useTagsStore, TagInfo } from '../stores/tags.store'; // 确保 TagInfo
import { useSessionStore } from '../stores/session.store';
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store';
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // +++ +++
import { useSettingsStore } from '../stores/settings.store'; // store
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ +++
import { useSettingsStore } from '../stores/settings.store';
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
import ManageTagConnectionsModal from './ManageTagConnectionsModal.vue';
//
@@ -23,15 +24,15 @@ const tagsStore = useTagsStore();
const sessionStore = useSessionStore(); // session store
const focusSwitcherStore = useFocusSwitcherStore(); // +++ Store +++
const uiNotificationsStore = useUiNotificationsStore(); // +++ +++
const settingsStore = useSettingsStore(); // store
const settingsStore = useSettingsStore(); // store
const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore);
const { tags, isLoading: tagsLoading, error: tagsError } = storeToRefs(tagsStore);
const { showConnectionTagsBoolean } = storeToRefs(settingsStore); //
const { showConnectionTagsBoolean } = storeToRefs(settingsStore); //
//
const searchTerm = ref('');
const searchInputRef = ref<HTMLInputElement | null>(null); // ref
const searchInputRef = ref<HTMLInputElement | null>(null); // ref
//
const contextMenuVisible = ref(false);
@@ -43,6 +44,10 @@ const tagContextMenuVisible = ref(false);
const tagContextMenuPosition = ref({ x: 0, y: 0 });
const contextTargetTagGroup = ref<(typeof filteredAndGroupedConnections.value)[0] | null>(null);
// +++ +++
const showManageTagModal = ref(false);
const tagToManage = ref<TagInfo | null>(null);
// +++ +++
const EXPANDED_GROUPS_STORAGE_KEY = 'workspaceConnectionListExpandedGroups';
@@ -213,7 +218,7 @@ const filteredAndGroupedConnections = computed(() => {
return result;
});
// ( showConnectionTagsBoolean false )
// ( showConnectionTagsBoolean false )
const flatFilteredConnections = computed(() => {
const lowerSearchTerm = searchTerm.value.toLowerCase();
const tagMap = new Map(tags.value.map(tag => [tag.id, tag.name])); // tagMap
@@ -405,8 +410,9 @@ const closeTagContextMenu = () => {
};
//
const handleTagMenuAction = (action: 'connectAll') => {
const group = contextTargetTagGroup.value;
// groupData
const handleTagMenuAction = (action: 'connectAll' | 'manageTag', directGroupData?: (typeof filteredAndGroupedConnections.value)[0]) => {
const group = directGroupData || contextTargetTagGroup.value; // 使 groupData
closeTagContextMenu(); //
if (group && action === 'connectAll') {
@@ -426,9 +432,29 @@ const handleTagMenuAction = (action: 'connectAll') => {
type: 'info',
});
}
} else if (group && action === 'manageTag') {
if (group.tagId !== null) { // ""
tagToManage.value = {
id: group.tagId,
name: group.groupName,
created_at: tags.value.find(t => t.id === group.tagId)?.created_at || Date.now() / 1000, //
updated_at: tags.value.find(t => t.id === group.tagId)?.updated_at || Date.now() / 1000,
};
showManageTagModal.value = true;
} else {
uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.manageTags.cannotManageUntagged'), //
type: 'warning',
});
}
}
};
const handleManageTagModalSaved = () => {
connectionsStore.fetchConnections(); //
tagsStore.fetchTags(); //
};
//
// handleConnect
setTimeout(() => {
@@ -715,8 +741,17 @@ const cancelEditingTag = () => {
</span>
<!-- 占位符占据剩余空间 -->
<div class="flex-grow min-w-0"></div>
</div>
<!-- Connection Items List -->
<!-- 标签栏右侧的编辑按钮 -->
<button
v-if="groupData.tagId !== null && editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId)"
@click.stop="handleTagMenuAction('manageTag', groupData)"
class="ml-2 px-1 h-6 flex items-center justify-center rounded text-text-secondary hover:text-primary hover:bg-black/10 opacity-0 group-hover:opacity-100 transition-all duration-150 focus:outline-none"
:title="t('workspaceConnectionList.manageTags.menuItem')"
>
<i class="fas fa-edit fa-xs"></i>
</button>
</div>
<!-- Connection Items List -->
<ul v-show="expandedGroups[groupData.groupName]" class="list-none p-0 m-0 pl-3">
<!-- ... li v-for="conn in groupData.connections" ... -->
<li
@@ -809,6 +844,15 @@ const cancelEditingTag = () => {
<i class="fas fa-ban mr-3 w-4 text-center text-text-disabled"></i>
<span>{{ t('workspaceConnectionList.noSshConnectionsToConnectMenu') }}</span>
</li>
<li class="my-1 border-t border-border/50" v-if="contextTargetTagGroup && contextTargetTagGroup.tagId !== null"></li>
<li
v-if="contextTargetTagGroup && contextTargetTagGroup.tagId !== null"
class="group px-4 py-1.5 cursor-pointer flex items-center text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleTagMenuAction('manageTag')"
>
<i class="fas fa-tags mr-3 w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('workspaceConnectionList.manageTags.menuItem') }}</span>
</li>
<!-- Future: Add "Rename Tag" or "Delete Tag (if empty)" options here -->
</ul>
</div>
@@ -819,7 +863,12 @@ const cancelEditingTag = () => {
:connection="selectedRdpConnection"
@close="closeRdpModal"
/> -->
</div>
<ManageTagConnectionsModal
:tag-info="tagToManage"
v-model:visible="showManageTagModal"
@saved="handleManageTagModalSaved"
/>
</div>
</template>
<!-- Scoped styles removed, now using Tailwind utility classes -->