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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
55
frontend/src/composables/useTreeSearch.ts
Normal file
55
frontend/src/composables/useTreeSearch.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user