mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 03:07:20 +08:00
feat: 阶段提交
This commit is contained in:
@@ -24,7 +24,6 @@ const tabsContainerRef = ref<HTMLDivElement>()
|
||||
// 标签页滚轮横向滚动
|
||||
function handleTabsWheel(e: WheelEvent) {
|
||||
if (tabsContainerRef.value) {
|
||||
e.preventDefault()
|
||||
tabsContainerRef.value.scrollLeft += e.deltaY
|
||||
}
|
||||
}
|
||||
@@ -155,11 +154,18 @@ function handleDragOver(e: DragEvent, index: number) {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
dragOverIndex.value = index
|
||||
// 只在值变化时更新,避免高频触发导致闪烁
|
||||
if (dragOverIndex.value !== index) {
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverIndex.value = null
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
// 检查是否离开了整个 tabs 容器,而不是在标签页之间移动
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
if (!relatedTarget || !tabsContainerRef.value?.contains(relatedTarget)) {
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, toIndex: number) {
|
||||
@@ -176,6 +182,28 @@ function handleDragEnd() {
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
// 尾部放置区域的拖拽处理
|
||||
function handleDragOverEnd(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
const endIndex = editorStore.tabs.length
|
||||
if (dragOverIndex.value !== endIndex) {
|
||||
dragOverIndex.value = endIndex
|
||||
}
|
||||
}
|
||||
|
||||
function handleDropEnd(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
if (dragIndex.value !== null && dragIndex.value !== editorStore.tabs.length - 1) {
|
||||
// 移动到最后
|
||||
editorStore.reorderTabs(dragIndex.value, editorStore.tabs.length - 1)
|
||||
}
|
||||
dragIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
// 右键菜单
|
||||
const contextMenuOptions = computed(() => [
|
||||
{
|
||||
@@ -318,7 +346,7 @@ defineExpose({
|
||||
<div class="editor-pane">
|
||||
<!-- 标签页栏 -->
|
||||
<div class="tabs-bar" v-if="editorStore.tabs.length > 0">
|
||||
<div ref="tabsContainerRef" class="tabs-container" @wheel="handleTabsWheel">
|
||||
<div ref="tabsContainerRef" class="tabs-container" @wheel.prevent="handleTabsWheel">
|
||||
<div
|
||||
v-for="(tab, index) in editorStore.tabs"
|
||||
:key="tab.path"
|
||||
@@ -333,7 +361,7 @@ defineExpose({
|
||||
@contextmenu="handleContextMenu($event, tab.path)"
|
||||
@dragstart="handleDragStart($event, index)"
|
||||
@dragover="handleDragOver($event, index)"
|
||||
@dragleave="handleDragLeave"
|
||||
@dragleave="handleDragLeave($event)"
|
||||
@drop="handleDrop($event, index)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
@@ -352,6 +380,15 @@ defineExpose({
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
<!-- 尾部放置区域,用于拖拽到最后 -->
|
||||
<div
|
||||
v-if="dragIndex !== null"
|
||||
class="tab-drop-end"
|
||||
:class="{ 'drag-over': dragOverIndex === editorStore.tabs.length }"
|
||||
@dragover="handleDragOverEnd($event)"
|
||||
@dragleave="handleDragLeave($event)"
|
||||
@drop="handleDropEnd($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -361,11 +398,7 @@ defineExpose({
|
||||
<i-mdi-file-document-outline class="empty-icon" />
|
||||
<p>{{ $gettext('Select a file to edit') }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-show="editorStore.tabs.length > 0"
|
||||
ref="containerRef"
|
||||
class="monaco-container"
|
||||
/>
|
||||
<div v-show="editorStore.tabs.length > 0" ref="containerRef" class="monaco-container" />
|
||||
<div v-if="editorStore.activeTab?.loading" class="loading-overlay">
|
||||
<n-spin size="medium" />
|
||||
</div>
|
||||
@@ -391,12 +424,14 @@ defineExpose({
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-width: 0; /* 允许在 flex 布局中收缩 */
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid v-bind('themeVars.borderColor');
|
||||
background: v-bind('themeVars.cardColor');
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
@@ -418,7 +453,9 @@ defineExpose({
|
||||
cursor: pointer;
|
||||
border-right: 1px solid v-bind('themeVars.borderColor');
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
opacity 0.2s;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
|
||||
@@ -450,6 +487,15 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.tab-drop-end {
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.drag-over {
|
||||
border-left: 2px solid v-bind('themeVars.primaryColor');
|
||||
}
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
font-size: 13px;
|
||||
max-width: 150px;
|
||||
|
||||
@@ -83,7 +83,6 @@ function handleIndentChange(value: { tabSize: number; insertSpaces: boolean }) {
|
||||
<div class="editor-status-bar" v-if="editorStore.activeTab">
|
||||
<!-- 文件路径 -->
|
||||
<div class="status-item path">
|
||||
{{ $gettext('Path') }}:
|
||||
<n-ellipsis style="max-width: 400px">
|
||||
{{ editorStore.activeTab.path }}
|
||||
</n-ellipsis>
|
||||
|
||||
@@ -157,6 +157,13 @@ function handleToggleWordWrap() {
|
||||
const current = editorStore.settings.wordWrap
|
||||
editorStore.updateSettings({ wordWrap: current === 'on' ? 'off' : 'on' })
|
||||
}
|
||||
|
||||
// 暴露方法供外部调用
|
||||
defineExpose({
|
||||
save: handleSave,
|
||||
saveAll: handleSaveAll,
|
||||
refresh: handleRefresh
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -24,6 +24,7 @@ const rootPath = ref(props.initialPath || editorStore.rootPath || '/')
|
||||
// 编辑器面板引用
|
||||
const editorPaneRef = ref<InstanceType<typeof EditorPane>>()
|
||||
const fileTreeRef = ref<InstanceType<typeof FileTree>>()
|
||||
const toolbarRef = ref<InstanceType<typeof EditorToolbar>>()
|
||||
|
||||
// 设置弹窗
|
||||
const showSettings = ref(false)
|
||||
@@ -63,16 +64,23 @@ watch(rootPath, (newPath) => {
|
||||
|
||||
// 键盘快捷键
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Ctrl+S 保存
|
||||
if (e.ctrlKey && e.key === 's' && !e.shiftKey) {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
const modKey = isMac ? e.metaKey : e.ctrlKey
|
||||
|
||||
// Ctrl/Cmd+S 保存
|
||||
if (modKey && e.key === 's' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
// 触发保存
|
||||
const toolbar = document.querySelector('.editor-toolbar button') as HTMLButtonElement
|
||||
toolbar?.click()
|
||||
toolbarRef.value?.save()
|
||||
}
|
||||
// Ctrl+Shift+S 全部保存
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'S') {
|
||||
// Ctrl/Cmd+Shift+S 全部保存
|
||||
if (modKey && e.shiftKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
toolbarRef.value?.saveAll()
|
||||
}
|
||||
// F5 或 Ctrl/Cmd+R 刷新当前文件
|
||||
if (e.key === 'F5' || (modKey && e.key === 'r')) {
|
||||
e.preventDefault()
|
||||
toolbarRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +103,7 @@ defineExpose({
|
||||
<div class="file-editor-view">
|
||||
<!-- 顶部工具栏 -->
|
||||
<EditorToolbar
|
||||
ref="toolbarRef"
|
||||
@search="handleSearch"
|
||||
@replace="handleReplace"
|
||||
@goto="handleGoto"
|
||||
@@ -315,13 +324,14 @@ defineExpose({
|
||||
|
||||
.editor-content {
|
||||
height: 100%;
|
||||
overflow: visible; /* 允许 Monaco tooltip 溢出 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: visible; /* 允许 Monaco tooltip 溢出 */
|
||||
overflow: hidden;
|
||||
min-width: 0; /* 允许在 flex 布局中收缩 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEditorStore } from '@/store'
|
||||
import { decodeBase64 } from '@/utils'
|
||||
import { getExt, getIconByExt } from '@/utils/file'
|
||||
import type { TreeOption } from 'naive-ui'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { NInput, useThemeVars } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
@@ -26,13 +26,33 @@ const expandedKeys = ref<string[]>([])
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const searchResults = ref<TreeOption[]>([])
|
||||
const isSearchMode = computed(() => searchKeyword.value.trim().length > 0)
|
||||
|
||||
// 新建文件/目录弹窗
|
||||
const showCreateModal = ref(false)
|
||||
const createType = ref<'file' | 'dir'>('file')
|
||||
const createName = ref('')
|
||||
const createParentPath = ref('')
|
||||
const createLoading = ref(false)
|
||||
// 内联新建状态
|
||||
const inlineCreateType = ref<'file' | 'dir' | null>(null)
|
||||
const inlineCreateName = ref('')
|
||||
const inlineCreateParentPath = ref('')
|
||||
const inlineCreateLoading = ref(false)
|
||||
|
||||
// 内联新建节点的特殊 key
|
||||
const INLINE_CREATE_KEY = '__inline_create__'
|
||||
|
||||
// 创建内联新建节点
|
||||
function createInlineNode(): TreeOption {
|
||||
return {
|
||||
key: INLINE_CREATE_KEY,
|
||||
label: '',
|
||||
isLeaf: true,
|
||||
prefix: () =>
|
||||
h(TheIcon, {
|
||||
icon: inlineCreateType.value === 'dir' ? 'mdi:folder' : 'mdi:file-outline',
|
||||
size: 18,
|
||||
color: inlineCreateType.value === 'dir' ? '#f59e0b' : '#6b7280'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 加载目录内容
|
||||
async function loadDirectory(path: string): Promise<TreeOption[]> {
|
||||
@@ -103,7 +123,17 @@ async function handleSelect(keys: string[], option: TreeOption[]) {
|
||||
selectedKeys.value = keys
|
||||
|
||||
const node = option[0]
|
||||
if (node && node.isLeaf) {
|
||||
const isDir = (node as any)?.isDir
|
||||
|
||||
// 搜索模式下点击文件夹,跳转到该目录
|
||||
if (isSearchMode.value && isDir) {
|
||||
const path = node.key as string
|
||||
searchKeyword.value = ''
|
||||
emit('update:rootPath', path)
|
||||
return
|
||||
}
|
||||
|
||||
if (node && node.isLeaf && !isDir) {
|
||||
// 打开文件
|
||||
const path = node.key as string
|
||||
const existingTab = editorStore.tabs.find((t) => t.path === path)
|
||||
@@ -144,25 +174,39 @@ function handleRefresh() {
|
||||
initTree()
|
||||
}
|
||||
|
||||
// 显示新建弹窗
|
||||
// 显示内联新建
|
||||
function showCreate(type: 'file' | 'dir') {
|
||||
createType.value = type
|
||||
createName.value = ''
|
||||
// 使用选中的目录或根目录作为父目录
|
||||
// 如果已经在新建中,先取消
|
||||
if (inlineCreateType.value) {
|
||||
cancelInlineCreate()
|
||||
}
|
||||
|
||||
inlineCreateType.value = type
|
||||
inlineCreateName.value = ''
|
||||
|
||||
// 确定父目录
|
||||
if (selectedKeys.value.length > 0) {
|
||||
const selectedNode = findNode(treeData.value, selectedKeys.value[0])
|
||||
if (selectedNode && !selectedNode.isLeaf) {
|
||||
createParentPath.value = selectedKeys.value[0]
|
||||
// 选中的是目录,在该目录下新建
|
||||
inlineCreateParentPath.value = selectedKeys.value[0]
|
||||
// 确保目录已展开
|
||||
if (!expandedKeys.value.includes(selectedKeys.value[0])) {
|
||||
expandedKeys.value = [...expandedKeys.value, selectedKeys.value[0]]
|
||||
}
|
||||
} else {
|
||||
// 如果选中的是文件,使用其父目录
|
||||
// 选中的是文件,在其父目录下新建
|
||||
const parts = selectedKeys.value[0].split('/')
|
||||
parts.pop()
|
||||
createParentPath.value = parts.join('/') || props.rootPath
|
||||
inlineCreateParentPath.value = parts.join('/') || props.rootPath
|
||||
}
|
||||
} else {
|
||||
createParentPath.value = props.rootPath
|
||||
// 没有选中,在根目录下新建
|
||||
inlineCreateParentPath.value = props.rootPath
|
||||
}
|
||||
showCreateModal.value = true
|
||||
|
||||
// 插入内联新建节点
|
||||
insertInlineNode()
|
||||
}
|
||||
|
||||
// 查找节点
|
||||
@@ -177,52 +221,235 @@ function findNode(nodes: TreeOption[], key: string): TreeOption | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// 确认新建
|
||||
async function handleCreate() {
|
||||
if (!createName.value.trim()) {
|
||||
window.$message.warning($gettext('Please enter a name'))
|
||||
// 插入内联新建节点
|
||||
function insertInlineNode() {
|
||||
const inlineNode = createInlineNode()
|
||||
|
||||
if (inlineCreateParentPath.value === props.rootPath) {
|
||||
// 在根目录下新建,插入到 treeData 开头
|
||||
removeInlineNode()
|
||||
treeData.value = [inlineNode, ...treeData.value]
|
||||
} else {
|
||||
// 在子目录下新建
|
||||
const parentNode = findNode(treeData.value, inlineCreateParentPath.value)
|
||||
if (parentNode) {
|
||||
removeInlineNode()
|
||||
if (!parentNode.children) {
|
||||
parentNode.children = []
|
||||
}
|
||||
parentNode.children = [inlineNode, ...parentNode.children]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除内联新建节点
|
||||
function removeInlineNode() {
|
||||
// 从根目录移除
|
||||
treeData.value = treeData.value.filter((n) => n.key !== INLINE_CREATE_KEY)
|
||||
|
||||
// 从所有子目录移除
|
||||
function removeFromChildren(nodes: TreeOption[]) {
|
||||
for (const node of nodes) {
|
||||
if (node.children) {
|
||||
node.children = node.children.filter((n) => n.key !== INLINE_CREATE_KEY)
|
||||
removeFromChildren(node.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
removeFromChildren(treeData.value)
|
||||
}
|
||||
|
||||
// 取消内联新建
|
||||
function cancelInlineCreate() {
|
||||
removeInlineNode()
|
||||
inlineCreateType.value = null
|
||||
inlineCreateName.value = ''
|
||||
inlineCreateParentPath.value = ''
|
||||
}
|
||||
|
||||
// 确认内联新建
|
||||
async function confirmInlineCreate() {
|
||||
if (!inlineCreateName.value.trim()) {
|
||||
cancelInlineCreate()
|
||||
return
|
||||
}
|
||||
|
||||
const fullPath = `${createParentPath.value}/${createName.value}`.replace(/\/+/g, '/')
|
||||
const fullPath = `${inlineCreateParentPath.value}/${inlineCreateName.value}`.replace(/\/+/g, '/')
|
||||
const isDir = inlineCreateType.value === 'dir'
|
||||
|
||||
createLoading.value = true
|
||||
useRequest(file.create(fullPath, createType.value === 'dir'))
|
||||
inlineCreateLoading.value = true
|
||||
useRequest(file.create(fullPath, isDir))
|
||||
.onSuccess(async () => {
|
||||
window.$message.success($gettext('Created successfully'))
|
||||
showCreateModal.value = false
|
||||
|
||||
// 保存当前新建的类型和路径
|
||||
const parentPath = inlineCreateParentPath.value
|
||||
const createdPath = fullPath
|
||||
|
||||
// 取消内联状态
|
||||
cancelInlineCreate()
|
||||
|
||||
// 刷新父目录
|
||||
if (expandedKeys.value.includes(createParentPath.value)) {
|
||||
const parentNode = findNode(treeData.value, createParentPath.value)
|
||||
if (parentNode) {
|
||||
parentNode.children = await loadDirectory(createParentPath.value)
|
||||
}
|
||||
} else if (createParentPath.value === props.rootPath) {
|
||||
// 如果是在根目录创建,刷新整个树
|
||||
if (parentPath === props.rootPath) {
|
||||
await initTree()
|
||||
} else {
|
||||
// 展开父目录
|
||||
expandedKeys.value = [...expandedKeys.value, createParentPath.value]
|
||||
const parentNode = findNode(treeData.value, parentPath)
|
||||
if (parentNode) {
|
||||
parentNode.children = await loadDirectory(parentPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是文件,自动打开
|
||||
if (createType.value === 'file') {
|
||||
editorStore.openFile(fullPath, '', 'utf-8')
|
||||
if (!isDir) {
|
||||
editorStore.openFile(createdPath, '', 'utf-8')
|
||||
}
|
||||
})
|
||||
.onError(() => {
|
||||
window.$message.error($gettext('Failed to create'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
createLoading.value = false
|
||||
inlineCreateLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
function filterTree(pattern: string, option: TreeOption): boolean {
|
||||
if (!pattern) return true
|
||||
return (option.label as string).toLowerCase().includes(pattern.toLowerCase())
|
||||
// 搜索文件
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function doSearch(keyword: string) {
|
||||
if (!keyword.trim()) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchLoading.value = true
|
||||
useRequest(file.list(props.rootPath, keyword, true, 'name', 1, 100))
|
||||
.onSuccess(({ data }) => {
|
||||
const items = data.items || []
|
||||
// 排序:目录在前,文件在后
|
||||
const sortedItems = [...items].sort((a: any, b: any) => {
|
||||
if (a.dir && !b.dir) return -1
|
||||
if (!a.dir && b.dir) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
searchResults.value = sortedItems.map((item: any) => ({
|
||||
key: item.full,
|
||||
label: item.name,
|
||||
fullPath: item.full,
|
||||
isLeaf: true, // 搜索结果都设为叶子节点,文件夹点击时跳转而不是展开
|
||||
prefix: () =>
|
||||
h(TheIcon, {
|
||||
icon: item.dir ? 'mdi:folder' : getIconByExt(getExt(item.name)),
|
||||
size: 18,
|
||||
color: item.dir ? '#f59e0b' : '#6b7280'
|
||||
}),
|
||||
isDir: item.dir
|
||||
}))
|
||||
})
|
||||
.onError(() => {
|
||||
searchResults.value = []
|
||||
})
|
||||
.onComplete(() => {
|
||||
searchLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watch(searchKeyword, (keyword) => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
doSearch(keyword)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
// 自定义渲染 label
|
||||
function renderLabel({ option }: { option: TreeOption }) {
|
||||
// 内联新建节点
|
||||
if (option.key === INLINE_CREATE_KEY) {
|
||||
return h(NInput, {
|
||||
value: inlineCreateName.value,
|
||||
'onUpdate:value': (v: string) => {
|
||||
inlineCreateName.value = v
|
||||
},
|
||||
size: 'tiny',
|
||||
placeholder:
|
||||
inlineCreateType.value === 'dir' ? $gettext('Folder name') : $gettext('File name'),
|
||||
autofocus: true,
|
||||
disabled: inlineCreateLoading.value,
|
||||
style: { width: '120px' },
|
||||
onKeyup: (e: KeyboardEvent) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') {
|
||||
confirmInlineCreate()
|
||||
} else if (e.key === 'Escape') {
|
||||
cancelInlineCreate()
|
||||
}
|
||||
},
|
||||
onBlur: () => {
|
||||
setTimeout(() => {
|
||||
if (inlineCreateType.value && !inlineCreateLoading.value) {
|
||||
if (inlineCreateName.value.trim()) {
|
||||
confirmInlineCreate()
|
||||
} else {
|
||||
cancelInlineCreate()
|
||||
}
|
||||
}
|
||||
}, 150)
|
||||
},
|
||||
onClick: (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 内联重命名节点
|
||||
if (option.key === inlineRenameKey.value) {
|
||||
return h(NInput, {
|
||||
value: inlineRenameName.value,
|
||||
'onUpdate:value': (v: string) => {
|
||||
inlineRenameName.value = v
|
||||
},
|
||||
size: 'tiny',
|
||||
autofocus: true,
|
||||
disabled: inlineRenameLoading.value,
|
||||
style: { width: '120px' },
|
||||
onKeyup: (e: KeyboardEvent) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') {
|
||||
confirmInlineRename()
|
||||
} else if (e.key === 'Escape') {
|
||||
cancelInlineRename()
|
||||
}
|
||||
},
|
||||
onBlur: () => {
|
||||
setTimeout(() => {
|
||||
if (inlineRenameKey.value && !inlineRenameLoading.value) {
|
||||
if (inlineRenameName.value.trim()) {
|
||||
confirmInlineRename()
|
||||
} else {
|
||||
cancelInlineRename()
|
||||
}
|
||||
}
|
||||
}, 150)
|
||||
},
|
||||
onClick: (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return option.label as string
|
||||
}
|
||||
|
||||
// 搜索结果渲染 label(显示完整路径)
|
||||
function renderSearchLabel({ option }: { option: TreeOption }) {
|
||||
const fullPath = (option as any).fullPath as string
|
||||
// 移除 rootPath 前缀,显示相对路径
|
||||
const relativePath = fullPath.startsWith(props.rootPath)
|
||||
? fullPath.slice(props.rootPath.length).replace(/^\//, '')
|
||||
: fullPath
|
||||
return relativePath || option.label
|
||||
}
|
||||
|
||||
// 路径编辑
|
||||
@@ -230,6 +457,189 @@ const isEditingPath = ref(false)
|
||||
const editingPath = ref('')
|
||||
const pathInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 右键菜单
|
||||
const showContextMenu = ref(false)
|
||||
const contextMenuX = ref(0)
|
||||
const contextMenuY = ref(0)
|
||||
const contextMenuNode = ref<TreeOption | null>(null)
|
||||
|
||||
// 内联重命名状态
|
||||
const inlineRenameKey = ref<string | null>(null)
|
||||
const inlineRenameName = ref('')
|
||||
const inlineRenameLoading = ref(false)
|
||||
|
||||
// 右键菜单选项
|
||||
const contextMenuOptions = computed(() => {
|
||||
if (!contextMenuNode.value) return []
|
||||
const isDir = !contextMenuNode.value.isLeaf
|
||||
const options = [
|
||||
{ label: $gettext('Rename'), key: 'rename' },
|
||||
{ label: $gettext('Delete'), key: 'delete', props: { style: { color: 'red' } } }
|
||||
]
|
||||
if (!isDir) {
|
||||
options.unshift({ label: $gettext('Download'), key: 'download' })
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
// 处理右键菜单
|
||||
function handleContextMenu(e: MouseEvent, option: TreeOption) {
|
||||
// 忽略内联新建节点
|
||||
if (option.key === INLINE_CREATE_KEY) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
showContextMenu.value = false
|
||||
nextTick(() => {
|
||||
contextMenuNode.value = option
|
||||
contextMenuX.value = e.clientX
|
||||
contextMenuY.value = e.clientY
|
||||
showContextMenu.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭右键菜单
|
||||
function handleContextMenuClose() {
|
||||
showContextMenu.value = false
|
||||
contextMenuNode.value = null
|
||||
}
|
||||
|
||||
// 处理右键菜单选择
|
||||
function handleContextMenuSelect(key: string) {
|
||||
if (!contextMenuNode.value) return
|
||||
|
||||
const nodePath = contextMenuNode.value.key as string
|
||||
const nodeName = contextMenuNode.value.label as string
|
||||
const isDir = !contextMenuNode.value.isLeaf
|
||||
|
||||
switch (key) {
|
||||
case 'rename':
|
||||
startInlineRename(nodePath, nodeName)
|
||||
break
|
||||
case 'delete':
|
||||
handleDelete(nodePath, nodeName, isDir)
|
||||
break
|
||||
case 'download':
|
||||
handleDownload(nodePath)
|
||||
break
|
||||
}
|
||||
|
||||
handleContextMenuClose()
|
||||
}
|
||||
|
||||
// 开始内联重命名
|
||||
function startInlineRename(path: string, name: string) {
|
||||
inlineRenameKey.value = path
|
||||
inlineRenameName.value = name
|
||||
}
|
||||
|
||||
// 取消内联重命名
|
||||
function cancelInlineRename() {
|
||||
inlineRenameKey.value = null
|
||||
inlineRenameName.value = ''
|
||||
}
|
||||
|
||||
// 确认内联重命名
|
||||
function confirmInlineRename() {
|
||||
if (!inlineRenameKey.value || !inlineRenameName.value.trim()) {
|
||||
cancelInlineRename()
|
||||
return
|
||||
}
|
||||
|
||||
const oldPath = inlineRenameKey.value
|
||||
const oldName = oldPath.split('/').pop() || ''
|
||||
const newName = inlineRenameName.value.trim()
|
||||
|
||||
if (oldName === newName) {
|
||||
cancelInlineRename()
|
||||
return
|
||||
}
|
||||
|
||||
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/')) || '/'
|
||||
const newPath = `${parentPath}/${newName}`.replace(/\/+/g, '/')
|
||||
|
||||
inlineRenameLoading.value = true
|
||||
useRequest(file.move([{ source: oldPath, target: newPath, force: false }]))
|
||||
.onSuccess(async () => {
|
||||
window.$message.success($gettext('Renamed successfully'))
|
||||
|
||||
// 如果重命名的文件在编辑器中打开,更新标签页
|
||||
const tab = editorStore.tabs.find((t) => t.path === oldPath)
|
||||
if (tab) {
|
||||
editorStore.closeTab(oldPath)
|
||||
editorStore.openFile(newPath, tab.content, tab.encoding)
|
||||
if (!tab.modified) {
|
||||
editorStore.markSaved(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
cancelInlineRename()
|
||||
|
||||
// 刷新父目录
|
||||
if (parentPath === props.rootPath) {
|
||||
await initTree()
|
||||
} else {
|
||||
const parentNode = findNode(treeData.value, parentPath)
|
||||
if (parentNode) {
|
||||
parentNode.children = await loadDirectory(parentPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
.onError(() => {
|
||||
window.$message.error($gettext('Failed to rename'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
inlineRenameLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 删除文件/目录
|
||||
function handleDelete(path: string, name: string, isDir: boolean) {
|
||||
window.$dialog.warning({
|
||||
title: $gettext('Delete'),
|
||||
content: $gettext('Are you sure you want to delete %{ name }?', { name }),
|
||||
positiveText: $gettext('Delete'),
|
||||
negativeText: $gettext('Cancel'),
|
||||
onPositiveClick: async () => {
|
||||
useRequest(file.delete(path))
|
||||
.onSuccess(async () => {
|
||||
window.$message.success($gettext('Deleted successfully'))
|
||||
|
||||
// 如果删除的文件在编辑器中打开,关闭标签页
|
||||
if (!isDir) {
|
||||
editorStore.closeTab(path)
|
||||
}
|
||||
|
||||
// 刷新父目录
|
||||
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
|
||||
if (parentPath === props.rootPath) {
|
||||
await initTree()
|
||||
} else {
|
||||
const parentNode = findNode(treeData.value, parentPath)
|
||||
if (parentNode) {
|
||||
parentNode.children = await loadDirectory(parentPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
.onError(() => {
|
||||
window.$message.error($gettext('Failed to delete'))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
function handleDownload(path: string) {
|
||||
window.open('/api/file/download?path=' + encodeURIComponent(path))
|
||||
}
|
||||
|
||||
// 节点属性(用于绑定右键菜单)
|
||||
function nodeProps({ option }: { option: TreeOption }) {
|
||||
return {
|
||||
onContextmenu: (e: MouseEvent) => handleContextMenu(e, option)
|
||||
}
|
||||
}
|
||||
|
||||
function startEditPath() {
|
||||
editingPath.value = props.rootPath
|
||||
isEditingPath.value = true
|
||||
@@ -339,15 +749,37 @@ defineExpose({
|
||||
|
||||
<!-- 文件树 -->
|
||||
<div class="tree-container">
|
||||
<n-spin :show="loading" class="tree-spin">
|
||||
<!-- 搜索模式:显示搜索结果 -->
|
||||
<n-spin v-if="isSearchMode" :show="searchLoading" class="tree-spin">
|
||||
<n-tree
|
||||
v-if="searchResults.length > 0"
|
||||
block-line
|
||||
:data="searchResults"
|
||||
:selected-keys="selectedKeys"
|
||||
:render-label="renderSearchLabel"
|
||||
:node-props="nodeProps"
|
||||
@update:selected-keys="handleSelect"
|
||||
selectable
|
||||
virtual-scroll
|
||||
class="file-tree-content"
|
||||
style="height: 100%"
|
||||
/>
|
||||
<n-empty
|
||||
v-else-if="!searchLoading"
|
||||
:description="$gettext('No results found')"
|
||||
class="search-empty"
|
||||
/>
|
||||
</n-spin>
|
||||
<!-- 普通模式:显示文件树 -->
|
||||
<n-spin v-else :show="loading" class="tree-spin">
|
||||
<n-tree
|
||||
block-line
|
||||
:data="treeData"
|
||||
:expanded-keys="expandedKeys"
|
||||
:selected-keys="selectedKeys"
|
||||
:pattern="searchKeyword"
|
||||
:filter="filterTree"
|
||||
:on-load="handleLoad"
|
||||
:render-label="renderLabel"
|
||||
:node-props="nodeProps"
|
||||
@update:expanded-keys="handleExpandedKeysUpdate"
|
||||
@update:selected-keys="handleSelect"
|
||||
selectable
|
||||
@@ -359,31 +791,17 @@ defineExpose({
|
||||
</n-spin>
|
||||
</div>
|
||||
|
||||
<!-- 新建弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showCreateModal"
|
||||
preset="dialog"
|
||||
:title="createType === 'file' ? $gettext('New File') : $gettext('New Folder')"
|
||||
:positive-text="$gettext('Create')"
|
||||
:negative-text="$gettext('Cancel')"
|
||||
:loading="createLoading"
|
||||
@positive-click="handleCreate"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item :label="$gettext('Parent Directory')">
|
||||
<n-input :value="createParentPath" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Name')">
|
||||
<n-input
|
||||
v-model:value="createName"
|
||||
:placeholder="
|
||||
createType === 'file' ? $gettext('Enter file name') : $gettext('Enter folder name')
|
||||
"
|
||||
@keyup.enter="handleCreate"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
<!-- 右键菜单 -->
|
||||
<n-dropdown
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
:options="contextMenuOptions"
|
||||
:show="showContextMenu"
|
||||
@select="handleContextMenuSelect"
|
||||
@clickoutside="handleContextMenuClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -471,4 +889,8 @@ defineExpose({
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user