- 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>
167 lines
4.5 KiB
Vue
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>
|