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>
This commit is contained in:
liumangmang
2026-04-03 16:10:49 +08:00
parent 55953dce83
commit cf7b564b3a
3 changed files with 111 additions and 4 deletions

View File

@@ -4,10 +4,11 @@ import { useSessionTreeStore } from '../stores/sessionTree'
import { useWorkspaceStore } from '../stores/workspace'
import { useTreeDragDrop } from '../composables/useTreeDragDrop'
import { useKeyboardShortcuts } from '../composables/useKeyboardShortcuts'
import { useTreeSearch } from '../composables/useTreeSearch'
import SessionTreeNode from './SessionTreeNode.vue'
import ContextMenu from './ContextMenu.vue'
import type { ContextMenuItem } from './ContextMenu.vue'
import { FolderPlus, Edit2, Trash2 } from 'lucide-vue-next'
import { FolderPlus, Edit2, Trash2, Search, X } from 'lucide-vue-next'
const treeStore = useSessionTreeStore()
const workspaceStore = useWorkspaceStore()
@@ -18,13 +19,22 @@ const showRenameDialog = ref(false)
const renameNodeId = ref<string | null>(null)
const renameValue = ref('')
// Search functionality
const nodesRef = computed(() => treeStore.nodes)
const { searchQuery, filteredNodes, clearSearch, isSearchMatch } = useTreeSearch(nodesRef)
// Context menu
const showContextMenu = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
const contextMenuNodeId = ref<string | null>(null)
const rootNodes = computed(() => treeStore.rootNodes)
const rootNodes = computed(() => {
const nodes = searchQuery.value ? filteredNodes.value : treeStore.nodes
return nodes
.filter(n => n.parentId === null)
.sort((a, b) => a.order - b.order)
})
const contextMenuItems = computed<ContextMenuItem[]>(() => {
if (!contextMenuNodeId.value) return []
@@ -64,6 +74,9 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
const dragHandlers = useTreeDragDrop()
provide('dragHandlers', dragHandlers)
// Provide search match checker
provide('isSearchMatch', isSearchMatch)
// Keyboard shortcuts
useKeyboardShortcuts([
{
@@ -89,6 +102,14 @@ useKeyboardShortcuts([
showNewFolderDialog.value = true
},
},
{
key: 'f',
ctrl: true,
handler: () => {
const searchInput = document.querySelector('.tree-search-input') as HTMLInputElement
searchInput?.focus()
},
},
])
function handleNodeClick(nodeId: string) {
@@ -162,14 +183,37 @@ function deleteNode(nodeId: string) {
<button
@click="showNewFolderDialog = true"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs text-slate-300 hover:bg-slate-800 transition-colors"
title="新建文件夹"
title="新建文件夹 (Ctrl+N)"
>
<FolderPlus class="w-4 h-4" />
文件夹
</button>
</div>
<!-- Search Bar -->
<div class="px-2 py-2 border-b border-slate-700">
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
v-model="searchQuery"
type="text"
placeholder="搜索会话... (Ctrl+F)"
class="tree-search-input w-full pl-8 pr-8 py-1.5 rounded bg-slate-800 border border-slate-700 text-slate-200 text-sm placeholder-slate-500 focus:border-cyan-500 focus:outline-none"
/>
<button
v-if="searchQuery"
@click="clearSearch"
class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
>
<X class="w-4 h-4" />
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-2">
<div v-if="searchQuery && rootNodes.length === 0" class="text-center py-8 text-slate-500 text-sm">
未找到匹配的会话
</div>
<SessionTreeNode
v-for="node in rootNodes"
:key="node.id"

View File

@@ -32,6 +32,7 @@ const connection = computed(() => {
// 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
@@ -138,8 +139,15 @@ function handleDragEnd() {
<span class="text-sm truncate flex-1">{{ node.name }}</span>
<!-- Search match indicator -->
<span
v-if="connection"
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>

View File

@@ -0,0 +1,55 @@
import { ref, computed, type Ref } from 'vue'
import type { SessionTreeNode } from '../types/sessionTree'
export function useTreeSearch(nodesRef: Ref<SessionTreeNode[]>) {
const searchQuery = ref('')
const searchResults = ref<Set<string>>(new Set())
const filteredNodes = computed(() => {
const nodes = nodesRef.value
if (!searchQuery.value.trim()) {
return nodes
}
const query = searchQuery.value.toLowerCase()
const results = new Set<string>()
const matchedNodes = new Set<string>()
// Find all matching nodes
nodes.forEach(node => {
if (node.name.toLowerCase().includes(query)) {
matchedNodes.add(node.id)
results.add(node.id)
// Add all ancestors to results
let current = node
while (current.parentId) {
results.add(current.parentId)
current = nodes.find(n => n.id === current.parentId)!
}
}
})
searchResults.value = matchedNodes
// Filter nodes to only show matched paths
return nodes.filter(node => results.has(node.id))
})
function clearSearch() {
searchQuery.value = ''
searchResults.value.clear()
}
function isSearchMatch(nodeId: string): boolean {
return searchResults.value.has(nodeId)
}
return {
searchQuery,
filteredNodes,
clearSearch,
isSearchMatch,
}
}