From e23ba1c3c987df5f75531d3005f114518e851ff9 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Fri, 3 Apr 2026 15:55:39 +0800 Subject: [PATCH] feat: add keyboard shortcuts and context menu (Phase 6) Phase 6 - Enhancements: - Add useKeyboardShortcuts composable for global shortcuts - Implement keyboard shortcuts: F2 (rename), Delete, Ctrl+N (new folder) - Add ContextMenu component with positioning logic - Implement right-click context menu for tree nodes - Add rename dialog for nodes - Support delete with confirmation - Add "new subfolder" action for folders Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/ContextMenu.vue | 109 ++++++++++++ frontend/src/components/SessionTree.vue | 162 +++++++++++++++++- frontend/src/components/SessionTreeNode.vue | 7 + .../src/composables/useKeyboardShortcuts.ts | 34 ++++ 4 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ContextMenu.vue create mode 100644 frontend/src/composables/useKeyboardShortcuts.ts diff --git a/frontend/src/components/ContextMenu.vue b/frontend/src/components/ContextMenu.vue new file mode 100644 index 0000000..b24f8a8 --- /dev/null +++ b/frontend/src/components/ContextMenu.vue @@ -0,0 +1,109 @@ + + + diff --git a/frontend/src/components/SessionTree.vue b/frontend/src/components/SessionTree.vue index 7e749e9..600b8a7 100644 --- a/frontend/src/components/SessionTree.vue +++ b/frontend/src/components/SessionTree.vue @@ -3,21 +3,94 @@ import { ref, computed, provide } from 'vue' import { useSessionTreeStore } from '../stores/sessionTree' import { useWorkspaceStore } from '../stores/workspace' import { useTreeDragDrop } from '../composables/useTreeDragDrop' +import { useKeyboardShortcuts } from '../composables/useKeyboardShortcuts' import SessionTreeNode from './SessionTreeNode.vue' -import { FolderPlus } from 'lucide-vue-next' +import ContextMenu from './ContextMenu.vue' +import type { ContextMenuItem } from './ContextMenu.vue' +import { FolderPlus, Edit2, Trash2 } from 'lucide-vue-next' const treeStore = useSessionTreeStore() const workspaceStore = useWorkspaceStore() const showNewFolderDialog = ref(false) const newFolderName = ref('') +const showRenameDialog = ref(false) +const renameNodeId = ref(null) +const renameValue = ref('') + +// Context menu +const showContextMenu = ref(false) +const contextMenuX = ref(0) +const contextMenuY = ref(0) +const contextMenuNodeId = ref(null) const rootNodes = computed(() => treeStore.rootNodes) +const contextMenuItems = computed(() => { + if (!contextMenuNodeId.value) return [] + + const node = treeStore.nodes.find(n => n.id === contextMenuNodeId.value) + if (!node) return [] + + const items: ContextMenuItem[] = [ + { + label: '重命名', + icon: Edit2, + action: () => startRename(node.id), + }, + ] + + if (node.type === 'folder') { + items.push({ + label: '新建子文件夹', + icon: FolderPlus, + action: () => createSubFolder(node.id), + }) + } + + items.push( + { divider: true } as ContextMenuItem, + { + label: '删除', + icon: Trash2, + action: () => deleteNode(node.id), + } + ) + + return items +}) + // Provide drag-drop handlers to child components const dragHandlers = useTreeDragDrop() provide('dragHandlers', dragHandlers) +// Keyboard shortcuts +useKeyboardShortcuts([ + { + key: 'F2', + handler: () => { + if (treeStore.selectedNodeId) { + startRename(treeStore.selectedNodeId) + } + }, + }, + { + key: 'Delete', + handler: () => { + if (treeStore.selectedNodeId) { + deleteNode(treeStore.selectedNodeId) + } + }, + }, + { + key: 'n', + ctrl: true, + handler: () => { + showNewFolderDialog.value = true + }, + }, +]) + function handleNodeClick(nodeId: string) { const node = treeStore.nodes.find(n => n.id === nodeId) if (!node) return @@ -31,12 +104,56 @@ function handleNodeClick(nodeId: string) { treeStore.selectNode(nodeId) } +function handleNodeContextMenu(nodeId: string, event: MouseEvent) { + event.preventDefault() + contextMenuNodeId.value = nodeId + contextMenuX.value = event.clientX + contextMenuY.value = event.clientY + showContextMenu.value = true + treeStore.selectNode(nodeId) +} + function createFolder() { if (!newFolderName.value.trim()) return treeStore.createFolder(newFolderName.value.trim()) newFolderName.value = '' showNewFolderDialog.value = false } + +function createSubFolder(_parentId: string) { + showNewFolderDialog.value = true + // TODO: Set parent context for new folder +} + +function startRename(nodeId: string) { + const node = treeStore.nodes.find(n => n.id === nodeId) + if (!node) return + + renameNodeId.value = nodeId + renameValue.value = node.name + showRenameDialog.value = true +} + +function confirmRename() { + if (!renameNodeId.value || !renameValue.value.trim()) return + treeStore.renameNode(renameNodeId.value, renameValue.value.trim()) + showRenameDialog.value = false + renameNodeId.value = null + renameValue.value = '' +} + +function deleteNode(nodeId: string) { + const node = treeStore.nodes.find(n => n.id === nodeId) + if (!node) return + + const confirmMsg = node.type === 'folder' + ? `确定要删除文件夹 "${node.name}" 及其所有内容吗?` + : `确定要删除连接 "${node.name}" 吗?` + + if (confirm(confirmMsg)) { + treeStore.deleteNode(nodeId) + } +} diff --git a/frontend/src/components/SessionTreeNode.vue b/frontend/src/components/SessionTreeNode.vue index d116886..5d84ad4 100644 --- a/frontend/src/components/SessionTreeNode.vue +++ b/frontend/src/components/SessionTreeNode.vue @@ -13,6 +13,7 @@ interface Props { const props = defineProps() const emit = defineEmits<{ click: [nodeId: string] + contextmenu: [nodeId: string, event: MouseEvent] }>() const treeStore = useSessionTreeStore() @@ -48,6 +49,10 @@ 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' @@ -93,6 +98,7 @@ function handleDragEnd() { }" :style="{ paddingLeft: `${level * 16 + 8}px` }" @click="handleClick" + @contextmenu="handleContextMenu" @dragstart="handleDragStart" @dragover="handleDragOver" @dragleave="handleDragLeave" @@ -145,6 +151,7 @@ function handleDragEnd() { :node="child" :level="level + 1" @click="emit('click', $event)" + @contextmenu="(nodeId, event) => emit('contextmenu', nodeId, event)" /> diff --git a/frontend/src/composables/useKeyboardShortcuts.ts b/frontend/src/composables/useKeyboardShortcuts.ts new file mode 100644 index 0000000..8af2811 --- /dev/null +++ b/frontend/src/composables/useKeyboardShortcuts.ts @@ -0,0 +1,34 @@ +import { onMounted, onUnmounted } from 'vue' + +export interface KeyboardShortcut { + key: string + ctrl?: boolean + shift?: boolean + alt?: boolean + handler: (event: KeyboardEvent) => void +} + +export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) { + function handleKeyDown(event: KeyboardEvent) { + for (const shortcut of shortcuts) { + const ctrlMatch = shortcut.ctrl === undefined || shortcut.ctrl === (event.ctrlKey || event.metaKey) + const shiftMatch = shortcut.shift === undefined || shortcut.shift === event.shiftKey + const altMatch = shortcut.alt === undefined || shortcut.alt === event.altKey + const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase() + + if (ctrlMatch && shiftMatch && altMatch && keyMatch) { + event.preventDefault() + shortcut.handler(event) + break + } + } + } + + onMounted(() => { + window.addEventListener('keydown', handleKeyDown) + }) + + onUnmounted(() => { + window.removeEventListener('keydown', handleKeyDown) + }) +}