Files
ssh-manager/frontend/src/components/SessionTreeNode.vue
liumangmang cf7b564b3a feat: add session tree search functionality
- Add useTreeSearch composable with filtering logic
- Implement search bar with Ctrl+F shortcut
- Show search results with ancestor paths
- Add search match indicator (cyan dot)
- Display "no results" message when search is empty
- Support clear search with X button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:10:49 +08:00

167 lines
4.5 KiB
Vue

<script setup lang="ts">
import { computed, inject } from 'vue'
import { useSessionTreeStore } from '../stores/sessionTree'
import { useConnectionsStore } from '../stores/connections'
import type { SessionTreeNode as TreeNode } from '../types/sessionTree'
import { Folder, FolderOpen, ChevronRight, ChevronDown, Server } from 'lucide-vue-next'
interface Props {
node: TreeNode
level: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
click: [nodeId: string]
contextmenu: [nodeId: string, event: MouseEvent]
}>()
const treeStore = useSessionTreeStore()
const connectionsStore = useConnectionsStore()
const children = computed(() => treeStore.getChildren(props.node.id))
const isSelected = computed(() => treeStore.selectedNodeId === props.node.id)
const isExpanded = computed(() => props.node.expanded)
const connection = computed(() => {
if (props.node.type === 'connection' && props.node.connectionId) {
return connectionsStore.connections.find(c => c.id === props.node.connectionId)
}
return null
})
// Drag-drop functionality
const dragHandlers = inject<any>('dragHandlers', null)
const isSearchMatch = inject<(nodeId: string) => boolean>('isSearchMatch', () => false)
const isDragging = computed(() =>
dragHandlers?.draggedNode.value?.id === props.node.id
)
const isDropTarget = computed(() =>
dragHandlers?.dropTarget.value?.id === props.node.id
)
const dropPosition = computed(() =>
isDropTarget.value ? dragHandlers?.dropPosition.value : null
)
function handleClick() {
emit('click', props.node.id)
}
function handleContextMenu(event: MouseEvent) {
emit('contextmenu', props.node.id, event)
}
function handleDragStart(event: DragEvent) {
if (dragHandlers) {
event.dataTransfer!.effectAllowed = 'move'
dragHandlers.handleDragStart(props.node)
}
}
function handleDragOver(event: DragEvent) {
if (dragHandlers) {
dragHandlers.handleDragOver(event, props.node)
}
}
function handleDragLeave() {
if (dragHandlers) {
dragHandlers.handleDragLeave()
}
}
function handleDrop(event: DragEvent) {
if (dragHandlers) {
dragHandlers.handleDrop(event)
}
}
function handleDragEnd() {
if (dragHandlers) {
dragHandlers.handleDragEnd()
}
}
</script>
<template>
<div>
<div
draggable="true"
class="flex items-center gap-1.5 px-2 py-1.5 rounded cursor-pointer hover:bg-slate-800 transition-colors relative"
:class="{
'bg-slate-800 text-cyan-300': isSelected,
'text-slate-300': !isSelected,
'opacity-50': isDragging,
'ring-2 ring-cyan-500': isDropTarget && dropPosition === 'inside',
}"
:style="{ paddingLeft: `${level * 16 + 8}px` }"
@click="handleClick"
@contextmenu="handleContextMenu"
@dragstart="handleDragStart"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@dragend="handleDragEnd"
>
<!-- Drop indicator - before -->
<div
v-if="isDropTarget && dropPosition === 'before'"
class="absolute top-0 left-0 right-0 h-0.5 bg-cyan-500"
/>
<!-- Drop indicator - after -->
<div
v-if="isDropTarget && dropPosition === 'after'"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-cyan-500"
/>
<button
v-if="node.type === 'folder'"
class="w-4 h-4 flex items-center justify-center"
@click.stop="treeStore.toggleExpanded(node.id)"
>
<ChevronDown v-if="isExpanded" class="w-3.5 h-3.5" />
<ChevronRight v-else class="w-3.5 h-3.5" />
</button>
<div v-else class="w-4" />
<component
:is="node.type === 'folder' ? (isExpanded ? FolderOpen : Folder) : Server"
class="w-4 h-4 flex-shrink-0"
:class="{
'text-amber-400': node.type === 'folder',
'text-cyan-400': node.type === 'connection',
}"
/>
<span class="text-sm truncate flex-1">{{ node.name }}</span>
<!-- Search match indicator -->
<span
v-if="isSearchMatch(node.id)"
class="w-1.5 h-1.5 rounded-full bg-cyan-400 flex-shrink-0"
title="搜索匹配"
/>
<span
v-else-if="connection"
class="w-2 h-2 rounded-full bg-slate-600"
/>
</div>
<template v-if="node.type === 'folder' && isExpanded">
<SessionTreeNode
v-for="child in children"
:key="child.id"
:node="child"
:level="level + 1"
@click="emit('click', $event)"
@contextmenu="(nodeId, event) => emit('contextmenu', nodeId, event)"
/>
</template>
</div>
</template>