mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 03:07:20 +08:00
feat: 阶段提交
This commit is contained in:
520
web/src/components/file-editor/EditorPane.vue
Normal file
520
web/src/components/file-editor/EditorPane.vue
Normal file
@@ -0,0 +1,520 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore, useThemeStore } from '@/store'
|
||||
import { languageByPath } from '@/utils/file'
|
||||
import { getMonaco } from '@/utils/monaco'
|
||||
import type * as Monaco from 'monaco-editor'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const editorStore = useEditorStore()
|
||||
const themeStore = useThemeStore()
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
const props = defineProps<{
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const editorRef = shallowRef<Monaco.editor.IStandaloneCodeEditor>()
|
||||
const monacoRef = shallowRef<typeof Monaco>()
|
||||
const editorReady = ref(false)
|
||||
const tabsContainerRef = ref<HTMLDivElement>()
|
||||
|
||||
// 标签页滚轮横向滚动
|
||||
function handleTabsWheel(e: WheelEvent) {
|
||||
if (tabsContainerRef.value) {
|
||||
e.preventDefault()
|
||||
tabsContainerRef.value.scrollLeft += e.deltaY
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化编辑器
|
||||
async function initEditor() {
|
||||
if (!containerRef.value) return
|
||||
|
||||
const monaco = await getMonaco(themeStore.locale)
|
||||
monacoRef.value = monaco
|
||||
|
||||
const settings = editorStore.settings
|
||||
editorRef.value = monaco.editor.create(containerRef.value, {
|
||||
value: '',
|
||||
language: 'plaintext',
|
||||
theme: 'vs' + (themeStore.darkMode ? '-dark' : ''),
|
||||
readOnly: props.readOnly,
|
||||
automaticLayout: true,
|
||||
// Basic settings
|
||||
tabSize: settings.tabSize,
|
||||
insertSpaces: settings.insertSpaces,
|
||||
wordWrap: settings.wordWrap,
|
||||
fontSize: settings.fontSize,
|
||||
minimap: { enabled: settings.minimap },
|
||||
// Display settings
|
||||
lineNumbers: settings.lineNumbers,
|
||||
renderWhitespace: settings.renderWhitespace,
|
||||
'bracketPairColorization.enabled': settings.bracketPairColorization,
|
||||
guides: {
|
||||
indentation: settings.guides,
|
||||
bracketPairs: settings.guides
|
||||
},
|
||||
folding: settings.folding,
|
||||
// Cursor settings
|
||||
cursorStyle: settings.cursorStyle,
|
||||
cursorBlinking: settings.cursorBlinking,
|
||||
smoothScrolling: settings.smoothScrolling,
|
||||
// Behavior settings
|
||||
mouseWheelZoom: settings.mouseWheelZoom,
|
||||
formatOnPaste: settings.formatOnPaste,
|
||||
formatOnType: settings.formatOnType
|
||||
})
|
||||
|
||||
// 监听内容变化
|
||||
editorRef.value.onDidChangeModelContent(() => {
|
||||
if (!editorStore.activeTab) return
|
||||
const newValue = editorRef.value?.getValue() ?? ''
|
||||
editorStore.updateContent(editorStore.activeTab.path, newValue)
|
||||
})
|
||||
|
||||
// 监听光标位置变化
|
||||
editorRef.value.onDidChangeCursorPosition((e) => {
|
||||
if (!editorStore.activeTab) return
|
||||
editorStore.updateCursor(editorStore.activeTab.path, e.position.lineNumber, e.position.column)
|
||||
})
|
||||
|
||||
editorReady.value = true
|
||||
updateEditorContent()
|
||||
}
|
||||
|
||||
// 更新编辑器内容
|
||||
function updateEditorContent() {
|
||||
if (!editorRef.value || !monacoRef.value) return
|
||||
|
||||
const tab = editorStore.activeTab
|
||||
if (!tab) {
|
||||
editorRef.value.setValue('')
|
||||
return
|
||||
}
|
||||
|
||||
// 更新内容
|
||||
const currentValue = editorRef.value.getValue()
|
||||
if (currentValue !== tab.content) {
|
||||
editorRef.value.setValue(tab.content)
|
||||
}
|
||||
|
||||
// 更新语言
|
||||
const model = editorRef.value.getModel()
|
||||
if (model) {
|
||||
const language = languageByPath(tab.path)
|
||||
monacoRef.value.editor.setModelLanguage(model, language)
|
||||
|
||||
// 更新主题(nginx 特殊处理)
|
||||
const theme =
|
||||
(language === 'nginx' ? 'nginx-theme' : 'vs') + (themeStore.darkMode ? '-dark' : '')
|
||||
monacoRef.value.editor.setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭标签页
|
||||
function handleCloseTab(path: string, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
const tab = editorStore.tabs.find((t) => t.path === path)
|
||||
if (tab?.modified) {
|
||||
window.$dialog.warning({
|
||||
title: $gettext('Unsaved Changes'),
|
||||
content: $gettext('This file has unsaved changes. Are you sure you want to close it?'),
|
||||
positiveText: $gettext('Close'),
|
||||
negativeText: $gettext('Cancel'),
|
||||
onPositiveClick: () => {
|
||||
editorStore.closeTab(path)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
editorStore.closeTab(path)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换标签页
|
||||
function handleSwitchTab(path: string) {
|
||||
editorStore.switchTab(path)
|
||||
}
|
||||
|
||||
// 拖拽排序
|
||||
const dragIndex = ref<number | null>(null)
|
||||
const dragOverIndex = ref<number | null>(null)
|
||||
|
||||
function handleDragStart(e: DragEvent, index: number) {
|
||||
dragIndex.value = index
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', String(index))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent, index: number) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, toIndex: number) {
|
||||
e.preventDefault()
|
||||
if (dragIndex.value !== null && dragIndex.value !== toIndex) {
|
||||
editorStore.reorderTabs(dragIndex.value, toIndex)
|
||||
}
|
||||
dragIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
dragIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
// 右键菜单
|
||||
const contextMenuOptions = computed(() => [
|
||||
{
|
||||
label: $gettext('Close'),
|
||||
key: 'close'
|
||||
},
|
||||
{
|
||||
label: $gettext('Close Others'),
|
||||
key: 'closeOthers'
|
||||
},
|
||||
{
|
||||
label: $gettext('Close All'),
|
||||
key: 'closeAll'
|
||||
},
|
||||
{
|
||||
label: $gettext('Close Saved'),
|
||||
key: 'closeSaved'
|
||||
}
|
||||
])
|
||||
|
||||
const contextMenuX = ref(0)
|
||||
const contextMenuY = ref(0)
|
||||
const showContextMenu = ref(false)
|
||||
const contextMenuPath = ref('')
|
||||
|
||||
function handleContextMenu(e: MouseEvent, path: string) {
|
||||
e.preventDefault()
|
||||
contextMenuPath.value = path
|
||||
contextMenuX.value = e.clientX
|
||||
contextMenuY.value = e.clientY
|
||||
showContextMenu.value = true
|
||||
}
|
||||
|
||||
function handleContextMenuSelect(key: string) {
|
||||
showContextMenu.value = false
|
||||
switch (key) {
|
||||
case 'close':
|
||||
handleCloseTab(contextMenuPath.value, new MouseEvent('click'))
|
||||
break
|
||||
case 'closeOthers':
|
||||
editorStore.closeOtherTabs(contextMenuPath.value)
|
||||
break
|
||||
case 'closeAll':
|
||||
editorStore.closeAllTabs()
|
||||
break
|
||||
case 'closeSaved':
|
||||
editorStore.closeSavedTabs()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside() {
|
||||
showContextMenu.value = false
|
||||
}
|
||||
|
||||
// 监听当前标签页变化
|
||||
watch(
|
||||
() => editorStore.activeTabPath,
|
||||
() => {
|
||||
if (editorReady.value) {
|
||||
updateEditorContent()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听当前标签页内容变化(外部更新)
|
||||
watch(
|
||||
() => editorStore.activeTab?.content,
|
||||
(newContent) => {
|
||||
if (!editorRef.value || !editorStore.activeTab) return
|
||||
const currentValue = editorRef.value.getValue()
|
||||
if (newContent !== undefined && currentValue !== newContent) {
|
||||
editorRef.value.setValue(newContent)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听主题变化
|
||||
watch(
|
||||
() => themeStore.darkMode,
|
||||
() => {
|
||||
if (!monacoRef.value || !editorStore.activeTab) return
|
||||
const language = languageByPath(editorStore.activeTab.path)
|
||||
const theme =
|
||||
(language === 'nginx' ? 'nginx-theme' : 'vs') + (themeStore.darkMode ? '-dark' : '')
|
||||
monacoRef.value.editor.setTheme(theme)
|
||||
}
|
||||
)
|
||||
|
||||
// 监听编辑器设置变化
|
||||
watch(
|
||||
() => editorStore.settings,
|
||||
(settings) => {
|
||||
if (!editorRef.value) return
|
||||
editorRef.value.updateOptions({
|
||||
// Basic settings
|
||||
tabSize: settings.tabSize,
|
||||
insertSpaces: settings.insertSpaces,
|
||||
wordWrap: settings.wordWrap,
|
||||
fontSize: settings.fontSize,
|
||||
minimap: { enabled: settings.minimap },
|
||||
// Display settings
|
||||
lineNumbers: settings.lineNumbers,
|
||||
renderWhitespace: settings.renderWhitespace,
|
||||
'bracketPairColorization.enabled': settings.bracketPairColorization,
|
||||
guides: {
|
||||
indentation: settings.guides,
|
||||
bracketPairs: settings.guides
|
||||
},
|
||||
folding: settings.folding,
|
||||
// Cursor settings
|
||||
cursorStyle: settings.cursorStyle,
|
||||
cursorBlinking: settings.cursorBlinking,
|
||||
smoothScrolling: settings.smoothScrolling,
|
||||
// Behavior settings
|
||||
mouseWheelZoom: settings.mouseWheelZoom,
|
||||
formatOnPaste: settings.formatOnPaste,
|
||||
formatOnType: settings.formatOnType
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initEditor()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.dispose()
|
||||
})
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
getEditor: () => editorRef.value,
|
||||
focus: () => editorRef.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-pane">
|
||||
<!-- 标签页栏 -->
|
||||
<div class="tabs-bar" v-if="editorStore.tabs.length > 0">
|
||||
<div ref="tabsContainerRef" class="tabs-container" @wheel="handleTabsWheel">
|
||||
<div
|
||||
v-for="(tab, index) in editorStore.tabs"
|
||||
:key="tab.path"
|
||||
class="tab-item"
|
||||
:class="{
|
||||
active: tab.path === editorStore.activeTabPath,
|
||||
dragging: dragIndex === index,
|
||||
'drag-over': dragOverIndex === index && dragIndex !== index
|
||||
}"
|
||||
draggable="true"
|
||||
@click="handleSwitchTab(tab.path)"
|
||||
@contextmenu="handleContextMenu($event, tab.path)"
|
||||
@dragstart="handleDragStart($event, index)"
|
||||
@dragover="handleDragOver($event, index)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop($event, index)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<span class="tab-name" :class="{ modified: tab.modified }">
|
||||
{{ tab.name }}
|
||||
<span v-if="tab.modified" class="modified-dot">●</span>
|
||||
</span>
|
||||
<n-button
|
||||
quaternary
|
||||
size="tiny"
|
||||
class="close-btn"
|
||||
@click="handleCloseTab(tab.path, $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-close />
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器容器 -->
|
||||
<div class="editor-container">
|
||||
<div v-if="editorStore.tabs.length === 0" class="empty-state">
|
||||
<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-if="editorStore.activeTab?.loading" class="loading-overlay">
|
||||
<n-spin size="medium" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<n-dropdown
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
:options="contextMenuOptions"
|
||||
:show="showContextMenu"
|
||||
@select="handleContextMenuSelect"
|
||||
@clickoutside="handleClickOutside"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.editor-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid v-bind('themeVars.borderColor');
|
||||
background: v-bind('themeVars.cardColor');
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Edge */
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid v-bind('themeVars.borderColor');
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: v-bind('themeVars.buttonColor2Hover');
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: v-bind('themeVars.buttonColor2Hover');
|
||||
font-weight: 500;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: v-bind('themeVars.primaryColor');
|
||||
}
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.drag-over {
|
||||
border-left: 2px solid v-bind('themeVars.primaryColor');
|
||||
}
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
font-size: 13px;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.modified {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.modified-dot {
|
||||
color: v-bind('themeVars.warningColor');
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
opacity: 0.6;
|
||||
padding: 2px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: visible; /* 允许 tooltip 溢出显示 */
|
||||
}
|
||||
|
||||
.monaco-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: v-bind('themeVars.textColor3');
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
193
web/src/components/file-editor/EditorStatusBar.vue
Normal file
193
web/src/components/file-editor/EditorStatusBar.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '@/store'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const editorStore = useEditorStore()
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
// 支持的语言列表
|
||||
const languages = [
|
||||
'plaintext',
|
||||
'javascript',
|
||||
'typescript',
|
||||
'html',
|
||||
'css',
|
||||
'scss',
|
||||
'less',
|
||||
'json',
|
||||
'xml',
|
||||
'yaml',
|
||||
'markdown',
|
||||
'python',
|
||||
'go',
|
||||
'java',
|
||||
'php',
|
||||
'ruby',
|
||||
'rust',
|
||||
'c',
|
||||
'cpp',
|
||||
'csharp',
|
||||
'shell',
|
||||
'sql',
|
||||
'nginx',
|
||||
'dockerfile'
|
||||
]
|
||||
|
||||
// 支持的编码列表
|
||||
const encodings = ['utf-8', 'gbk', 'gb2312', 'iso-8859-1', 'utf-16', 'utf-16le', 'utf-16be']
|
||||
|
||||
// 缩进选项
|
||||
const indentOptions = computed(() => [
|
||||
{ label: `${$gettext('Spaces')}: 2`, value: { tabSize: 2, insertSpaces: true } },
|
||||
{ label: `${$gettext('Spaces')}: 4`, value: { tabSize: 4, insertSpaces: true } },
|
||||
{ label: `${$gettext('Tabs')}: 2`, value: { tabSize: 2, insertSpaces: false } },
|
||||
{ label: `${$gettext('Tabs')}: 4`, value: { tabSize: 4, insertSpaces: false } }
|
||||
])
|
||||
|
||||
// 当前缩进显示
|
||||
const currentIndent = computed(() => {
|
||||
const { tabSize, insertSpaces } = editorStore.settings
|
||||
return insertSpaces ? `${$gettext('Spaces')}: ${tabSize}` : `${$gettext('Tabs')}: ${tabSize}`
|
||||
})
|
||||
|
||||
// 更新行分隔符
|
||||
function handleLineEndingChange(value: 'LF' | 'CRLF') {
|
||||
if (editorStore.activeTab) {
|
||||
editorStore.updateLineEnding(editorStore.activeTab.path, value)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新编码
|
||||
function handleEncodingChange(value: string) {
|
||||
if (editorStore.activeTab) {
|
||||
editorStore.updateEncoding(editorStore.activeTab.path, value)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新语言
|
||||
function handleLanguageChange(value: string) {
|
||||
if (editorStore.activeTab) {
|
||||
editorStore.updateLanguage(editorStore.activeTab.path, value)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缩进
|
||||
function handleIndentChange(value: { tabSize: number; insertSpaces: boolean }) {
|
||||
editorStore.updateSettings(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="status-spacer" />
|
||||
|
||||
<!-- 行分隔符 -->
|
||||
<n-popselect
|
||||
:value="editorStore.activeTab.lineEnding"
|
||||
:options="[
|
||||
{ label: 'LF', value: 'LF' },
|
||||
{ label: 'CRLF', value: 'CRLF' }
|
||||
]"
|
||||
@update:value="handleLineEndingChange"
|
||||
>
|
||||
<div class="status-item clickable">
|
||||
{{ editorStore.activeTab.lineEnding }}
|
||||
</div>
|
||||
</n-popselect>
|
||||
|
||||
<!-- 光标位置 -->
|
||||
<div class="status-item">
|
||||
{{ $gettext('Ln') }} {{ editorStore.activeTab.cursorLine }}, {{ $gettext('Col') }}
|
||||
{{ editorStore.activeTab.cursorColumn }}
|
||||
</div>
|
||||
|
||||
<!-- 缩进 -->
|
||||
<n-popselect :options="indentOptions" @update:value="handleIndentChange">
|
||||
<div class="status-item clickable">
|
||||
{{ currentIndent }}
|
||||
</div>
|
||||
</n-popselect>
|
||||
|
||||
<!-- 编码 -->
|
||||
<n-popselect
|
||||
:value="editorStore.activeTab.encoding"
|
||||
:options="encodings.map((e) => ({ label: e.toUpperCase(), value: e }))"
|
||||
@update:value="handleEncodingChange"
|
||||
scrollable
|
||||
>
|
||||
<div class="status-item clickable">
|
||||
{{ $gettext('Encoding') }}: {{ editorStore.activeTab.encoding }}
|
||||
</div>
|
||||
</n-popselect>
|
||||
|
||||
<!-- 语言 -->
|
||||
<n-popselect
|
||||
:value="editorStore.activeTab.language"
|
||||
:options="languages.map((l) => ({ label: l, value: l }))"
|
||||
@update:value="handleLanguageChange"
|
||||
scrollable
|
||||
>
|
||||
<div class="status-item clickable">
|
||||
{{ $gettext('Language') }}: {{ editorStore.activeTab.language }}
|
||||
</div>
|
||||
</n-popselect>
|
||||
</div>
|
||||
<div class="editor-status-bar empty" v-else>
|
||||
<span class="status-item">{{ $gettext('No file open') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.editor-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
background: v-bind('themeVars.cardColor');
|
||||
border-top: 1px solid v-bind('themeVars.borderColor');
|
||||
flex-shrink: 0;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
|
||||
&.empty {
|
||||
color: v-bind('themeVars.textColor3');
|
||||
}
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: v-bind('themeVars.buttonColor2Hover');
|
||||
}
|
||||
}
|
||||
|
||||
&.path {
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.status-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
307
web/src/components/file-editor/EditorToolbar.vue
Normal file
307
web/src/components/file-editor/EditorToolbar.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<script setup lang="ts">
|
||||
import file from '@/api/panel/file'
|
||||
import { useEditorStore } from '@/store'
|
||||
import { decodeBase64 } from '@/utils'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const editorStore = useEditorStore()
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'search'): void
|
||||
(e: 'replace'): void
|
||||
(e: 'goto'): void
|
||||
(e: 'settings'): void
|
||||
}>()
|
||||
|
||||
const saving = ref(false)
|
||||
const savingAll = ref(false)
|
||||
|
||||
// 保存当前文件
|
||||
function handleSave() {
|
||||
const tab = editorStore.activeTab
|
||||
if (!tab) {
|
||||
window.$message.warning($gettext('No file to save'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!tab.modified) {
|
||||
window.$message.info($gettext('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
useRequest(file.save(tab.path, tab.content))
|
||||
.onSuccess(() => {
|
||||
editorStore.markSaved(tab.path)
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
saving.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 保存所有文件
|
||||
async function handleSaveAll() {
|
||||
const unsavedTabs = editorStore.unsavedTabs
|
||||
if (unsavedTabs.length === 0) {
|
||||
window.$message.info($gettext('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
savingAll.value = true
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const tab of unsavedTabs) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
useRequest(file.save(tab.path, tab.content))
|
||||
.onSuccess(() => {
|
||||
editorStore.markSaved(tab.path)
|
||||
successCount++
|
||||
resolve()
|
||||
})
|
||||
.onError(() => {
|
||||
failCount++
|
||||
reject()
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
// 继续处理下一个文件
|
||||
}
|
||||
}
|
||||
|
||||
savingAll.value = false
|
||||
|
||||
if (failCount === 0) {
|
||||
window.$message.success($gettext('All files saved successfully'))
|
||||
} else {
|
||||
window.$message.warning(
|
||||
$gettext('Saved %{ success } files, %{ fail } failed', {
|
||||
success: successCount,
|
||||
fail: failCount
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新当前文件
|
||||
function handleRefresh() {
|
||||
const tab = editorStore.activeTab
|
||||
if (!tab) return
|
||||
|
||||
if (tab.modified) {
|
||||
window.$dialog.warning({
|
||||
title: $gettext('Unsaved Changes'),
|
||||
content: $gettext('This file has unsaved changes. Refreshing will discard them. Continue?'),
|
||||
positiveText: $gettext('Refresh'),
|
||||
negativeText: $gettext('Cancel'),
|
||||
onPositiveClick: () => {
|
||||
doRefresh(tab.path)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
doRefresh(tab.path)
|
||||
}
|
||||
}
|
||||
|
||||
function doRefresh(path: string) {
|
||||
editorStore.setLoading(path, true)
|
||||
useRequest(file.content(encodeURIComponent(path)))
|
||||
.onSuccess(({ data }) => {
|
||||
const content = decodeBase64(data.content)
|
||||
editorStore.reloadFile(path, content)
|
||||
window.$message.success($gettext('Refreshed successfully'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
editorStore.setLoading(path, false)
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索
|
||||
function handleSearch() {
|
||||
emit('search')
|
||||
}
|
||||
|
||||
// 替换
|
||||
function handleReplace() {
|
||||
emit('replace')
|
||||
}
|
||||
|
||||
// 跳转行
|
||||
function handleGoto() {
|
||||
emit('goto')
|
||||
}
|
||||
|
||||
// 设置
|
||||
function handleSettings() {
|
||||
emit('settings')
|
||||
}
|
||||
|
||||
// 字体大小调整
|
||||
function handleFontSizeChange(delta: number) {
|
||||
const newSize = Math.max(10, Math.min(24, editorStore.settings.fontSize + delta))
|
||||
editorStore.updateSettings({ fontSize: newSize })
|
||||
}
|
||||
|
||||
// 切换小地图
|
||||
function handleToggleMinimap() {
|
||||
editorStore.updateSettings({ minimap: !editorStore.settings.minimap })
|
||||
}
|
||||
|
||||
// 切换自动换行
|
||||
function handleToggleWordWrap() {
|
||||
const current = editorStore.settings.wordWrap
|
||||
editorStore.updateSettings({ wordWrap: current === 'on' ? 'off' : 'on' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-toolbar">
|
||||
<n-flex align="center" :wrap="false">
|
||||
<!-- 文件操作 -->
|
||||
<n-button-group size="small">
|
||||
<n-button
|
||||
@click="handleSave"
|
||||
:disabled="!editorStore.activeTab?.modified"
|
||||
:loading="saving"
|
||||
:title="$gettext('Save (Ctrl+S)')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-content-save />
|
||||
</template>
|
||||
{{ $gettext('Save') }}
|
||||
</n-button>
|
||||
<n-button
|
||||
@click="handleSaveAll"
|
||||
:disabled="!editorStore.hasUnsavedFiles"
|
||||
:loading="savingAll"
|
||||
:title="$gettext('Save All (Ctrl+Shift+S)')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-content-save-all />
|
||||
</template>
|
||||
{{ $gettext('Save All') }}
|
||||
</n-button>
|
||||
<n-button
|
||||
@click="handleRefresh"
|
||||
:disabled="!editorStore.activeTab"
|
||||
:title="$gettext('Refresh')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-refresh />
|
||||
</template>
|
||||
{{ $gettext('Refresh') }}
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
|
||||
<n-divider vertical />
|
||||
|
||||
<!-- 编辑操作 -->
|
||||
<n-button-group size="small">
|
||||
<n-button
|
||||
@click="handleSearch"
|
||||
:disabled="!editorStore.activeTab"
|
||||
:title="$gettext('Search (Ctrl+F)')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-magnify />
|
||||
</template>
|
||||
{{ $gettext('Search') }}
|
||||
</n-button>
|
||||
<n-button
|
||||
@click="handleReplace"
|
||||
:disabled="!editorStore.activeTab"
|
||||
:title="$gettext('Replace (Ctrl+H)')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-find-replace />
|
||||
</template>
|
||||
{{ $gettext('Replace') }}
|
||||
</n-button>
|
||||
<n-button
|
||||
@click="handleGoto"
|
||||
:disabled="!editorStore.activeTab"
|
||||
:title="$gettext('Go to Line (Ctrl+G)')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-arrow-right-bold />
|
||||
</template>
|
||||
{{ $gettext('Go to') }}
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
|
||||
<n-divider vertical />
|
||||
|
||||
<!-- 视图操作 -->
|
||||
<n-button-group size="small">
|
||||
<n-button @click="handleFontSizeChange(-1)" :title="$gettext('Decrease Font Size')">
|
||||
<template #icon>
|
||||
<i-mdi-format-font-size-decrease />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button class="font-size-display">
|
||||
{{ editorStore.settings.fontSize }}
|
||||
</n-button>
|
||||
<n-button @click="handleFontSizeChange(1)" :title="$gettext('Increase Font Size')">
|
||||
<template #icon>
|
||||
<i-mdi-format-font-size-increase />
|
||||
</template>
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
|
||||
<n-button
|
||||
size="small"
|
||||
:type="editorStore.settings.wordWrap === 'on' ? 'primary' : 'default'"
|
||||
@click="handleToggleWordWrap"
|
||||
:title="$gettext('Toggle Word Wrap')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-wrap />
|
||||
</template>
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
size="small"
|
||||
:type="editorStore.settings.minimap ? 'primary' : 'default'"
|
||||
@click="handleToggleMinimap"
|
||||
:title="$gettext('Toggle Minimap')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-mdi-map-outline />
|
||||
</template>
|
||||
</n-button>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<!-- 设置 -->
|
||||
<n-button size="small" quaternary @click="handleSettings" :title="$gettext('Settings')">
|
||||
<template #icon>
|
||||
<i-mdi-cog />
|
||||
</template>
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.editor-toolbar {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid v-bind('themeVars.borderColor');
|
||||
background: v-bind('themeVars.cardColor');
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.font-size-display {
|
||||
min-width: 40px;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
327
web/src/components/file-editor/FileEditorView.vue
Normal file
327
web/src/components/file-editor/FileEditorView.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '@/store'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import EditorPane from './EditorPane.vue'
|
||||
import EditorStatusBar from './EditorStatusBar.vue'
|
||||
import EditorToolbar from './EditorToolbar.vue'
|
||||
import FileTree from './FileTree.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
const props = defineProps<{
|
||||
initialPath?: string
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
// 侧边栏折叠状态
|
||||
const siderCollapsed = ref(false)
|
||||
const siderWidth = ref(250)
|
||||
|
||||
// 文件树根目录
|
||||
const rootPath = ref(props.initialPath || editorStore.rootPath || '/')
|
||||
|
||||
// 编辑器面板引用
|
||||
const editorPaneRef = ref<InstanceType<typeof EditorPane>>()
|
||||
const fileTreeRef = ref<InstanceType<typeof FileTree>>()
|
||||
|
||||
// 设置弹窗
|
||||
const showSettings = ref(false)
|
||||
|
||||
// 处理工具栏事件
|
||||
function handleSearch() {
|
||||
const editor = editorPaneRef.value?.getEditor()
|
||||
if (editor) {
|
||||
editor.getAction('actions.find')?.run()
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplace() {
|
||||
const editor = editorPaneRef.value?.getEditor()
|
||||
if (editor) {
|
||||
editor.getAction('editor.action.startFindReplaceAction')?.run()
|
||||
}
|
||||
}
|
||||
|
||||
function handleGoto() {
|
||||
const editor = editorPaneRef.value?.getEditor()
|
||||
if (editor) {
|
||||
// 需要先聚焦编辑器,gotoLine 才能正常工作
|
||||
editor.focus()
|
||||
editor.getAction('editor.action.gotoLine')?.run()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettings() {
|
||||
showSettings.value = true
|
||||
}
|
||||
|
||||
// 监听根目录变化
|
||||
watch(rootPath, (newPath) => {
|
||||
editorStore.setRootPath(newPath)
|
||||
})
|
||||
|
||||
// 键盘快捷键
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Ctrl+S 保存
|
||||
if (e.ctrlKey && e.key === 's' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
// 触发保存
|
||||
const toolbar = document.querySelector('.editor-toolbar button') as HTMLButtonElement
|
||||
toolbar?.click()
|
||||
}
|
||||
// Ctrl+Shift+S 全部保存
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'S') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
refresh: () => fileTreeRef.value?.refresh(),
|
||||
focus: () => editorPaneRef.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-editor-view">
|
||||
<!-- 顶部工具栏 -->
|
||||
<EditorToolbar
|
||||
@search="handleSearch"
|
||||
@replace="handleReplace"
|
||||
@goto="handleGoto"
|
||||
@settings="handleSettings"
|
||||
/>
|
||||
|
||||
<!-- 主体区域 -->
|
||||
<div class="editor-main">
|
||||
<n-layout has-sider class="editor-layout">
|
||||
<!-- 左侧文件树 -->
|
||||
<n-layout-sider
|
||||
bordered
|
||||
:collapsed="siderCollapsed"
|
||||
:collapsed-width="0"
|
||||
:width="siderWidth"
|
||||
show-trigger="bar"
|
||||
collapse-mode="width"
|
||||
@update:collapsed="siderCollapsed = $event"
|
||||
class="file-tree-sider"
|
||||
>
|
||||
<FileTree ref="fileTreeRef" v-model:root-path="rootPath" />
|
||||
</n-layout-sider>
|
||||
|
||||
<!-- 右侧编辑器区域(包含编辑器和状态栏) -->
|
||||
<n-layout class="editor-content">
|
||||
<div class="editor-wrapper">
|
||||
<EditorPane ref="editorPaneRef" :read-only="readOnly" />
|
||||
<EditorStatusBar />
|
||||
</div>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</div>
|
||||
|
||||
<!-- 设置弹窗 -->
|
||||
<n-modal v-model:show="showSettings" preset="card" :title="$gettext('Editor Settings')" style="width: 600px">
|
||||
<n-scrollbar style="max-height: 70vh">
|
||||
<n-form label-placement="left" label-width="140" class="settings-form">
|
||||
<!-- 基础设置 -->
|
||||
<n-divider title-placement="left">{{ $gettext('Basic') }}</n-divider>
|
||||
|
||||
<n-form-item :label="$gettext('Tab Size')">
|
||||
<n-input-number
|
||||
v-model:value="editorStore.settings.tabSize"
|
||||
:min="1"
|
||||
:max="8"
|
||||
@update:value="(v) => editorStore.updateSettings({ tabSize: v || 4 })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Use Spaces')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.insertSpaces"
|
||||
@update:value="(v) => editorStore.updateSettings({ insertSpaces: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Font Size')">
|
||||
<n-input-number
|
||||
v-model:value="editorStore.settings.fontSize"
|
||||
:min="10"
|
||||
:max="24"
|
||||
@update:value="(v) => editorStore.updateSettings({ fontSize: v || 14 })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Word Wrap')">
|
||||
<n-select
|
||||
:value="editorStore.settings.wordWrap"
|
||||
:options="[
|
||||
{ label: $gettext('Off'), value: 'off' },
|
||||
{ label: $gettext('On'), value: 'on' },
|
||||
{ label: $gettext('Word Wrap Column'), value: 'wordWrapColumn' },
|
||||
{ label: $gettext('Bounded'), value: 'bounded' }
|
||||
]"
|
||||
@update:value="(v) => editorStore.updateSettings({ wordWrap: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Show Minimap')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.minimap"
|
||||
@update:value="(v) => editorStore.updateSettings({ minimap: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<!-- 显示设置 -->
|
||||
<n-divider title-placement="left">{{ $gettext('Display') }}</n-divider>
|
||||
|
||||
<n-form-item :label="$gettext('Line Numbers')">
|
||||
<n-select
|
||||
:value="editorStore.settings.lineNumbers"
|
||||
:options="[
|
||||
{ label: $gettext('On'), value: 'on' },
|
||||
{ label: $gettext('Off'), value: 'off' },
|
||||
{ label: $gettext('Relative'), value: 'relative' },
|
||||
{ label: $gettext('Interval'), value: 'interval' }
|
||||
]"
|
||||
@update:value="(v) => editorStore.updateSettings({ lineNumbers: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Render Whitespace')">
|
||||
<n-select
|
||||
:value="editorStore.settings.renderWhitespace"
|
||||
:options="[
|
||||
{ label: $gettext('None'), value: 'none' },
|
||||
{ label: $gettext('Boundary'), value: 'boundary' },
|
||||
{ label: $gettext('Selection'), value: 'selection' },
|
||||
{ label: $gettext('Trailing'), value: 'trailing' },
|
||||
{ label: $gettext('All'), value: 'all' }
|
||||
]"
|
||||
@update:value="(v) => editorStore.updateSettings({ renderWhitespace: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Bracket Colorization')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.bracketPairColorization"
|
||||
@update:value="(v) => editorStore.updateSettings({ bracketPairColorization: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Indent Guides')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.guides"
|
||||
@update:value="(v) => editorStore.updateSettings({ guides: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Code Folding')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.folding"
|
||||
@update:value="(v) => editorStore.updateSettings({ folding: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<!-- 光标设置 -->
|
||||
<n-divider title-placement="left">{{ $gettext('Cursor') }}</n-divider>
|
||||
|
||||
<n-form-item :label="$gettext('Cursor Style')">
|
||||
<n-select
|
||||
:value="editorStore.settings.cursorStyle"
|
||||
:options="[
|
||||
{ label: $gettext('Line'), value: 'line' },
|
||||
{ label: $gettext('Block'), value: 'block' },
|
||||
{ label: $gettext('Underline'), value: 'underline' },
|
||||
{ label: $gettext('Line Thin'), value: 'line-thin' },
|
||||
{ label: $gettext('Block Outline'), value: 'block-outline' },
|
||||
{ label: $gettext('Underline Thin'), value: 'underline-thin' }
|
||||
]"
|
||||
@update:value="(v) => editorStore.updateSettings({ cursorStyle: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Cursor Blinking')">
|
||||
<n-select
|
||||
:value="editorStore.settings.cursorBlinking"
|
||||
:options="[
|
||||
{ label: $gettext('Blink'), value: 'blink' },
|
||||
{ label: $gettext('Smooth'), value: 'smooth' },
|
||||
{ label: $gettext('Phase'), value: 'phase' },
|
||||
{ label: $gettext('Expand'), value: 'expand' },
|
||||
{ label: $gettext('Solid'), value: 'solid' }
|
||||
]"
|
||||
@update:value="(v) => editorStore.updateSettings({ cursorBlinking: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Smooth Scrolling')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.smoothScrolling"
|
||||
@update:value="(v) => editorStore.updateSettings({ smoothScrolling: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<!-- 行为设置 -->
|
||||
<n-divider title-placement="left">{{ $gettext('Behavior') }}</n-divider>
|
||||
|
||||
<n-form-item :label="$gettext('Mouse Wheel Zoom')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.mouseWheelZoom"
|
||||
@update:value="(v) => editorStore.updateSettings({ mouseWheelZoom: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Format On Paste')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.formatOnPaste"
|
||||
@update:value="(v) => editorStore.updateSettings({ formatOnPaste: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Format On Type')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.formatOnType"
|
||||
@update:value="(v) => editorStore.updateSettings({ formatOnType: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-scrollbar>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-editor-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--n-card-color);
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-layout {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.file-tree-sider {
|
||||
height: 100%;
|
||||
|
||||
:deep(.n-layout-sider-scroll-container) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
height: 100%;
|
||||
overflow: visible; /* 允许 Monaco tooltip 溢出 */
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: visible; /* 允许 Monaco tooltip 溢出 */
|
||||
}
|
||||
</style>
|
||||
474
web/src/components/file-editor/FileTree.vue
Normal file
474
web/src/components/file-editor/FileTree.vue
Normal file
@@ -0,0 +1,474 @@
|
||||
<script setup lang="ts">
|
||||
import file from '@/api/panel/file'
|
||||
import TheIcon from '@/components/custom/TheIcon.vue'
|
||||
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 { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const editorStore = useEditorStore()
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
const props = defineProps<{
|
||||
rootPath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:rootPath', path: string): void
|
||||
}>()
|
||||
|
||||
// 文件树数据
|
||||
const treeData = ref<TreeOption[]>([])
|
||||
const expandedKeys = ref<string[]>([])
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 新建文件/目录弹窗
|
||||
const showCreateModal = ref(false)
|
||||
const createType = ref<'file' | 'dir'>('file')
|
||||
const createName = ref('')
|
||||
const createParentPath = ref('')
|
||||
const createLoading = ref(false)
|
||||
|
||||
// 加载目录内容
|
||||
async function loadDirectory(path: string): Promise<TreeOption[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
useRequest(file.list(path, '', false, 'name', 1, 1000))
|
||||
.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)
|
||||
})
|
||||
resolve(
|
||||
sortedItems.map((item: any) => ({
|
||||
key: item.full,
|
||||
label: item.name,
|
||||
isLeaf: !item.dir,
|
||||
prefix: () =>
|
||||
h(TheIcon, {
|
||||
icon: item.dir ? 'mdi:folder' : getIconByExt(getExt(item.name)),
|
||||
size: 18,
|
||||
color: item.dir ? '#f59e0b' : '#6b7280'
|
||||
}),
|
||||
isDir: item.dir,
|
||||
children: item.dir ? undefined : undefined
|
||||
}))
|
||||
)
|
||||
})
|
||||
.onError(() => {
|
||||
reject(new Error('Failed to load directory'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化加载根目录
|
||||
async function initTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
treeData.value = await loadDirectory(props.rootPath)
|
||||
expandedKeys.value = []
|
||||
} catch {
|
||||
treeData.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 懒加载子目录
|
||||
async function handleLoad(node: TreeOption): Promise<void> {
|
||||
if (node.isLeaf) return
|
||||
try {
|
||||
const children = await loadDirectory(node.key as string)
|
||||
node.children = children
|
||||
} catch {
|
||||
node.children = []
|
||||
}
|
||||
}
|
||||
|
||||
// 展开节点
|
||||
function handleExpandedKeysUpdate(keys: string[]) {
|
||||
expandedKeys.value = keys
|
||||
}
|
||||
|
||||
// 选择节点(打开文件)
|
||||
async function handleSelect(keys: string[], option: TreeOption[]) {
|
||||
if (keys.length === 0) return
|
||||
selectedKeys.value = keys
|
||||
|
||||
const node = option[0]
|
||||
if (node && node.isLeaf) {
|
||||
// 打开文件
|
||||
const path = node.key as string
|
||||
const existingTab = editorStore.tabs.find((t) => t.path === path)
|
||||
if (existingTab) {
|
||||
editorStore.switchTab(path)
|
||||
} else {
|
||||
// 加载文件内容
|
||||
editorStore.openFile(path, '', 'utf-8')
|
||||
editorStore.setLoading(path, true)
|
||||
useRequest(file.content(encodeURIComponent(path)))
|
||||
.onSuccess(({ data }) => {
|
||||
const content = decodeBase64(data.content)
|
||||
editorStore.reloadFile(path, content)
|
||||
})
|
||||
.onError(() => {
|
||||
editorStore.closeTab(path)
|
||||
window.$message.error($gettext('Failed to load file'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
editorStore.setLoading(path, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上一级目录
|
||||
function handleGoUp() {
|
||||
const parts = props.rootPath.split('/').filter(Boolean)
|
||||
if (parts.length > 0) {
|
||||
parts.pop()
|
||||
const newPath = '/' + parts.join('/')
|
||||
emit('update:rootPath', newPath || '/')
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新
|
||||
function handleRefresh() {
|
||||
initTree()
|
||||
}
|
||||
|
||||
// 显示新建弹窗
|
||||
function showCreate(type: 'file' | 'dir') {
|
||||
createType.value = type
|
||||
createName.value = ''
|
||||
// 使用选中的目录或根目录作为父目录
|
||||
if (selectedKeys.value.length > 0) {
|
||||
const selectedNode = findNode(treeData.value, selectedKeys.value[0])
|
||||
if (selectedNode && !selectedNode.isLeaf) {
|
||||
createParentPath.value = selectedKeys.value[0]
|
||||
} else {
|
||||
// 如果选中的是文件,使用其父目录
|
||||
const parts = selectedKeys.value[0].split('/')
|
||||
parts.pop()
|
||||
createParentPath.value = parts.join('/') || props.rootPath
|
||||
}
|
||||
} else {
|
||||
createParentPath.value = props.rootPath
|
||||
}
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
// 查找节点
|
||||
function findNode(nodes: TreeOption[], key: string): TreeOption | null {
|
||||
for (const node of nodes) {
|
||||
if (node.key === key) return node
|
||||
if (node.children) {
|
||||
const found = findNode(node.children, key)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 确认新建
|
||||
async function handleCreate() {
|
||||
if (!createName.value.trim()) {
|
||||
window.$message.warning($gettext('Please enter a name'))
|
||||
return
|
||||
}
|
||||
|
||||
const fullPath = `${createParentPath.value}/${createName.value}`.replace(/\/+/g, '/')
|
||||
|
||||
createLoading.value = true
|
||||
useRequest(file.create(fullPath, createType.value === 'dir'))
|
||||
.onSuccess(async () => {
|
||||
window.$message.success($gettext('Created successfully'))
|
||||
showCreateModal.value = false
|
||||
|
||||
// 刷新父目录
|
||||
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) {
|
||||
// 如果是在根目录创建,刷新整个树
|
||||
await initTree()
|
||||
} else {
|
||||
// 展开父目录
|
||||
expandedKeys.value = [...expandedKeys.value, createParentPath.value]
|
||||
}
|
||||
|
||||
// 如果是文件,自动打开
|
||||
if (createType.value === 'file') {
|
||||
editorStore.openFile(fullPath, '', 'utf-8')
|
||||
}
|
||||
})
|
||||
.onError(() => {
|
||||
window.$message.error($gettext('Failed to create'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
createLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
function filterTree(pattern: string, option: TreeOption): boolean {
|
||||
if (!pattern) return true
|
||||
return (option.label as string).toLowerCase().includes(pattern.toLowerCase())
|
||||
}
|
||||
|
||||
// 路径编辑
|
||||
const isEditingPath = ref(false)
|
||||
const editingPath = ref('')
|
||||
const pathInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function startEditPath() {
|
||||
editingPath.value = props.rootPath
|
||||
isEditingPath.value = true
|
||||
nextTick(() => {
|
||||
pathInputRef.value?.focus()
|
||||
pathInputRef.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function confirmEditPath() {
|
||||
const newPath = editingPath.value.trim()
|
||||
if (newPath && newPath !== props.rootPath) {
|
||||
// 确保路径以 / 开头
|
||||
const normalizedPath = newPath.startsWith('/') ? newPath : '/' + newPath
|
||||
emit('update:rootPath', normalizedPath)
|
||||
}
|
||||
isEditingPath.value = false
|
||||
}
|
||||
|
||||
function cancelEditPath() {
|
||||
isEditingPath.value = false
|
||||
editingPath.value = ''
|
||||
}
|
||||
|
||||
// 监听根目录变化
|
||||
watch(
|
||||
() => props.rootPath,
|
||||
() => {
|
||||
initTree()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initTree()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
refresh: handleRefresh
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-tree">
|
||||
<!-- 工具栏 -->
|
||||
<div class="file-tree-toolbar">
|
||||
<n-button-group size="small">
|
||||
<n-button quaternary @click="handleGoUp" :title="$gettext('Go Up')">
|
||||
<template #icon>
|
||||
<i-mdi-arrow-up />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button quaternary @click="handleRefresh" :title="$gettext('Refresh')">
|
||||
<template #icon>
|
||||
<i-mdi-refresh />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-popselect
|
||||
:options="[
|
||||
{ label: $gettext('New File'), value: 'file' },
|
||||
{ label: $gettext('New Folder'), value: 'dir' }
|
||||
]"
|
||||
@update:value="showCreate"
|
||||
>
|
||||
<n-button quaternary :title="$gettext('New')">
|
||||
<template #icon>
|
||||
<i-mdi-plus />
|
||||
</template>
|
||||
</n-button>
|
||||
</n-popselect>
|
||||
</n-button-group>
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
size="small"
|
||||
:placeholder="$gettext('Search')"
|
||||
clearable
|
||||
class="search-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<i-mdi-magnify />
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
|
||||
<!-- 当前目录 -->
|
||||
<div class="current-path" @click="startEditPath" v-if="!isEditingPath">
|
||||
<the-icon icon="mdi:folder-open" :size="14" class="path-icon" />
|
||||
<n-ellipsis :tooltip="{ width: 300 }">
|
||||
{{ rootPath }}
|
||||
</n-ellipsis>
|
||||
<the-icon icon="mdi:pencil" :size="12" class="edit-icon" />
|
||||
</div>
|
||||
<div class="current-path editing" v-else>
|
||||
<n-input
|
||||
ref="pathInputRef"
|
||||
v-model:value="editingPath"
|
||||
size="tiny"
|
||||
:placeholder="$gettext('Enter path')"
|
||||
@keyup.enter="confirmEditPath"
|
||||
@keyup.escape="cancelEditPath"
|
||||
@blur="confirmEditPath"
|
||||
>
|
||||
<template #prefix>
|
||||
<the-icon icon="mdi:folder-open" :size="14" />
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
|
||||
<!-- 文件树 -->
|
||||
<div class="tree-container">
|
||||
<n-spin :show="loading" class="tree-spin">
|
||||
<n-tree
|
||||
block-line
|
||||
:data="treeData"
|
||||
:expanded-keys="expandedKeys"
|
||||
:selected-keys="selectedKeys"
|
||||
:pattern="searchKeyword"
|
||||
:filter="filterTree"
|
||||
:on-load="handleLoad"
|
||||
@update:expanded-keys="handleExpandedKeysUpdate"
|
||||
@update:selected-keys="handleSelect"
|
||||
selectable
|
||||
expand-on-click
|
||||
virtual-scroll
|
||||
class="file-tree-content"
|
||||
style="height: 100%"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-tree-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid v-bind('themeVars.borderColor');
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.current-path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
min-height: 32px;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
color: v-bind('themeVars.textColor3');
|
||||
border-bottom: 1px solid v-bind('themeVars.borderColor');
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover:not(.editing) {
|
||||
background-color: v-bind('themeVars.buttonColor2Hover');
|
||||
|
||||
.edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.editing {
|
||||
cursor: default;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.path-icon {
|
||||
flex-shrink: 0;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
:deep(.n-input) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tree-spin {
|
||||
height: 100%;
|
||||
|
||||
:deep(.n-spin-content) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.file-tree-content {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
5
web/src/components/file-editor/index.ts
Normal file
5
web/src/components/file-editor/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as FileEditorView } from './FileEditorView.vue'
|
||||
export { default as FileTree } from './FileTree.vue'
|
||||
export { default as EditorPane } from './EditorPane.vue'
|
||||
export { default as EditorToolbar } from './EditorToolbar.vue'
|
||||
export { default as EditorStatusBar } from './EditorStatusBar.vue'
|
||||
268
web/src/store/modules/editor/index.ts
Normal file
268
web/src/store/modules/editor/index.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { languageByPath } from '@/utils/file'
|
||||
|
||||
// 打开的文件标签页
|
||||
export interface EditorTab {
|
||||
path: string // 文件完整路径
|
||||
name: string // 文件名
|
||||
content: string // 文件内容
|
||||
originalContent: string // 原始内容(用于判断是否修改)
|
||||
language: string // 语言类型
|
||||
modified: boolean // 是否已修改
|
||||
loading: boolean // 是否正在加载
|
||||
encoding: string // 文件编码
|
||||
lineEnding: 'LF' | 'CRLF' // 行分隔符
|
||||
cursorLine: number // 光标行
|
||||
cursorColumn: number // 光标列
|
||||
}
|
||||
|
||||
// 编辑器设置
|
||||
export interface EditorSettings {
|
||||
tabSize: number // 缩进大小
|
||||
insertSpaces: boolean // 使用空格缩进
|
||||
wordWrap: 'on' | 'off' | 'wordWrapColumn' | 'bounded' // 自动换行
|
||||
fontSize: number // 字体大小
|
||||
minimap: boolean // 是否显示小地图
|
||||
// 高级设置
|
||||
lineNumbers: 'on' | 'off' | 'relative' | 'interval' // 行号显示
|
||||
renderWhitespace: 'none' | 'boundary' | 'selection' | 'trailing' | 'all' // 空白字符显示
|
||||
cursorBlinking: 'blink' | 'smooth' | 'phase' | 'expand' | 'solid' // 光标闪烁
|
||||
cursorStyle: 'line' | 'block' | 'underline' | 'line-thin' | 'block-outline' | 'underline-thin' // 光标样式
|
||||
smoothScrolling: boolean // 平滑滚动
|
||||
mouseWheelZoom: boolean // 鼠标滚轮缩放
|
||||
bracketPairColorization: boolean // 括号配对着色
|
||||
guides: boolean // 缩进参考线
|
||||
folding: boolean // 代码折叠
|
||||
formatOnPaste: boolean // 粘贴时格式化
|
||||
formatOnType: boolean // 输入时格式化
|
||||
}
|
||||
|
||||
export interface EditorState {
|
||||
tabs: EditorTab[] // 打开的标签页
|
||||
activeTabPath: string | null // 当前激活的标签页路径
|
||||
settings: EditorSettings // 编辑器设置
|
||||
rootPath: string // 文件树根目录
|
||||
}
|
||||
|
||||
const defaultSettings: EditorSettings = {
|
||||
tabSize: 4,
|
||||
insertSpaces: true,
|
||||
wordWrap: 'on',
|
||||
fontSize: 14,
|
||||
minimap: true
|
||||
}
|
||||
|
||||
export const useEditorStore = defineStore('editor', {
|
||||
state: (): EditorState => ({
|
||||
tabs: [],
|
||||
activeTabPath: null,
|
||||
settings: { ...defaultSettings },
|
||||
rootPath: '/'
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// 获取当前激活的标签页
|
||||
activeTab(): EditorTab | null {
|
||||
if (!this.activeTabPath) return null
|
||||
return this.tabs.find((tab) => tab.path === this.activeTabPath) || null
|
||||
},
|
||||
|
||||
// 是否有未保存的文件
|
||||
hasUnsavedFiles(): boolean {
|
||||
return this.tabs.some((tab) => tab.modified)
|
||||
},
|
||||
|
||||
// 获取未保存的文件列表
|
||||
unsavedTabs(): EditorTab[] {
|
||||
return this.tabs.filter((tab) => tab.modified)
|
||||
},
|
||||
|
||||
// 获取标签页索引
|
||||
activeTabIndex(): number {
|
||||
if (!this.activeTabPath) return -1
|
||||
return this.tabs.findIndex((tab) => tab.path === this.activeTabPath)
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 打开文件(添加标签页)
|
||||
openFile(path: string, content: string = '', encoding: string = 'utf-8') {
|
||||
const existingTab = this.tabs.find((tab) => tab.path === path)
|
||||
if (existingTab) {
|
||||
// 文件已打开,切换到该标签页
|
||||
this.activeTabPath = path
|
||||
return existingTab
|
||||
}
|
||||
|
||||
// 检测行分隔符
|
||||
const lineEnding = content.includes('\r\n') ? 'CRLF' : 'LF'
|
||||
|
||||
// 创建新标签页
|
||||
const newTab: EditorTab = {
|
||||
path,
|
||||
name: path.split('/').pop() || path,
|
||||
content,
|
||||
originalContent: content,
|
||||
language: languageByPath(path),
|
||||
modified: false,
|
||||
loading: false,
|
||||
encoding,
|
||||
lineEnding,
|
||||
cursorLine: 1,
|
||||
cursorColumn: 1
|
||||
}
|
||||
|
||||
this.tabs.push(newTab)
|
||||
this.activeTabPath = path
|
||||
return newTab
|
||||
},
|
||||
|
||||
// 关闭标签页
|
||||
closeTab(path: string) {
|
||||
const index = this.tabs.findIndex((tab) => tab.path === path)
|
||||
if (index === -1) return
|
||||
|
||||
this.tabs.splice(index, 1)
|
||||
|
||||
// 如果关闭的是当前激活的标签页,切换到相邻标签页
|
||||
if (this.activeTabPath === path) {
|
||||
if (this.tabs.length === 0) {
|
||||
this.activeTabPath = null
|
||||
} else if (index >= this.tabs.length) {
|
||||
this.activeTabPath = this.tabs[this.tabs.length - 1].path
|
||||
} else {
|
||||
this.activeTabPath = this.tabs[index].path
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 关闭所有标签页
|
||||
closeAllTabs() {
|
||||
this.tabs = []
|
||||
this.activeTabPath = null
|
||||
},
|
||||
|
||||
// 关闭其他标签页
|
||||
closeOtherTabs(path: string) {
|
||||
this.tabs = this.tabs.filter((tab) => tab.path === path)
|
||||
this.activeTabPath = path
|
||||
},
|
||||
|
||||
// 关闭已保存的标签页
|
||||
closeSavedTabs() {
|
||||
this.tabs = this.tabs.filter((tab) => tab.modified)
|
||||
if (this.tabs.length === 0) {
|
||||
this.activeTabPath = null
|
||||
} else if (!this.tabs.find((tab) => tab.path === this.activeTabPath)) {
|
||||
this.activeTabPath = this.tabs[0].path
|
||||
}
|
||||
},
|
||||
|
||||
// 切换标签页
|
||||
switchTab(path: string) {
|
||||
if (this.tabs.find((tab) => tab.path === path)) {
|
||||
this.activeTabPath = path
|
||||
}
|
||||
},
|
||||
|
||||
// 更新文件内容
|
||||
updateContent(path: string, content: string) {
|
||||
const tab = this.tabs.find((t) => t.path === path)
|
||||
if (tab) {
|
||||
tab.content = content
|
||||
tab.modified = content !== tab.originalContent
|
||||
}
|
||||
},
|
||||
|
||||
// 标记文件已保存
|
||||
markSaved(path: string) {
|
||||
const tab = this.tabs.find((t) => t.path === path)
|
||||
if (tab) {
|
||||
tab.originalContent = tab.content
|
||||
tab.modified = false
|
||||
}
|
||||
},
|
||||
|
||||
// 更新光标位置
|
||||
updateCursor(path: string, line: number, column: number) {
|
||||
const tab = this.tabs.find((t) => t.path === path)
|
||||
if (tab) {
|
||||
tab.cursorLine = line
|
||||
tab.cursorColumn = column
|
||||
}
|
||||
},
|
||||
|
||||
// 更新行分隔符
|
||||
updateLineEnding(path: string, lineEnding: 'LF' | 'CRLF') {
|
||||
const tab = this.tabs.find((t) => t.path === path)
|
||||
if (tab && tab.lineEnding !== lineEnding) {
|
||||
tab.lineEnding = lineEnding
|
||||
// 转换内容中的行分隔符
|
||||
if (lineEnding === 'CRLF') {
|
||||
tab.content = tab.content.replace(/(?<!\r)\n/g, '\r\n')
|
||||
} else {
|
||||
tab.content = tab.content.replace(/\r\n/g, '\n')
|
||||
}
|
||||
tab.modified = tab.content !== tab.originalContent
|
||||
}
|
||||
},
|
||||
|
||||
// 更新编码
|
||||
updateEncoding(path: string, encoding: string) {
|
||||
const tab = this.tabs.find((t) => t.path === path)
|
||||
if (tab) {
|
||||
tab.encoding = encoding
|
||||
}
|
||||
},
|
||||
|
||||
// 更新语言
|
||||
updateLanguage(path: string, language: string) {
|
||||
const tab = this.tabs.find((t) => t.path === path)
|
||||
if (tab) {
|
||||
tab.language = language
|
||||
}
|
||||
},
|
||||
|
||||
// 设置加载状态
|
||||
setLoading(path: string, loading: boolean) {
|
||||
const tab = this.tabs.find((t) => t.path === path)
|
||||
if (tab) {
|
||||
tab.loading = loading
|
||||
}
|
||||
},
|
||||
|
||||
// 更新编辑器设置
|
||||
updateSettings(settings: Partial<EditorSettings>) {
|
||||
this.settings = { ...this.settings, ...settings }
|
||||
},
|
||||
|
||||
// 设置根目录
|
||||
setRootPath(path: string) {
|
||||
this.rootPath = path
|
||||
},
|
||||
|
||||
// 重新加载文件内容
|
||||
reloadFile(path: string, content: string) {
|
||||
const tab = this.tabs.find((t) => t.path === path)
|
||||
if (tab) {
|
||||
tab.content = content
|
||||
tab.originalContent = content
|
||||
tab.modified = false
|
||||
tab.lineEnding = content.includes('\r\n') ? 'CRLF' : 'LF'
|
||||
}
|
||||
},
|
||||
|
||||
// 重新排序标签页
|
||||
reorderTabs(fromIndex: number, toIndex: number) {
|
||||
if (fromIndex === toIndex) return
|
||||
if (fromIndex < 0 || fromIndex >= this.tabs.length) return
|
||||
if (toIndex < 0 || toIndex >= this.tabs.length) return
|
||||
|
||||
const [movedTab] = this.tabs.splice(fromIndex, 1)
|
||||
this.tabs.splice(toIndex, 0, movedTab)
|
||||
}
|
||||
},
|
||||
|
||||
persist: {
|
||||
pick: ['settings', 'rootPath']
|
||||
}
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './editor'
|
||||
export * from './file'
|
||||
export * from './permission'
|
||||
export * from './tab'
|
||||
|
||||
@@ -1,38 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import FileEditor from '@/components/common/FileEditor.vue'
|
||||
import file from '@/api/panel/file'
|
||||
import { FileEditorView } from '@/components/file-editor'
|
||||
import { useEditorStore } from '@/store'
|
||||
import { decodeBase64 } from '@/utils'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
const file = defineModel<string>('file', { type: String, required: true })
|
||||
const editor = ref<any>(null)
|
||||
const filePath = defineModel<string>('file', { type: String, required: true })
|
||||
|
||||
const handleRefresh = () => {
|
||||
editor.value.get()
|
||||
}
|
||||
const editorRef = ref<InstanceType<typeof FileEditorView>>()
|
||||
|
||||
const handleSave = () => {
|
||||
editor.value.save()
|
||||
}
|
||||
// 获取文件所在目录作为初始路径
|
||||
const initialPath = computed(() => {
|
||||
if (!filePath.value) return '/'
|
||||
const parts = filePath.value.split('/')
|
||||
parts.pop()
|
||||
return parts.join('/') || '/'
|
||||
})
|
||||
|
||||
// 打开时自动加载文件
|
||||
watch(show, (newShow) => {
|
||||
if (newShow && filePath.value) {
|
||||
// 暂停文件管理的键盘快捷键
|
||||
window.$bus.emit('file:keyboard-pause')
|
||||
|
||||
// 清空之前的标签页
|
||||
editorStore.closeAllTabs()
|
||||
// 设置根目录
|
||||
editorStore.setRootPath(initialPath.value)
|
||||
// 打开指定文件
|
||||
editorStore.openFile(filePath.value, '', 'utf-8')
|
||||
editorStore.setLoading(filePath.value, true)
|
||||
|
||||
useRequest(file.content(encodeURIComponent(filePath.value)))
|
||||
.onSuccess(({ data }) => {
|
||||
const content = decodeBase64(data.content)
|
||||
editorStore.reloadFile(filePath.value, content)
|
||||
})
|
||||
.onError(() => {
|
||||
window.$message.error($gettext('Failed to load file'))
|
||||
editorStore.closeTab(filePath.value)
|
||||
})
|
||||
.onComplete(() => {
|
||||
editorStore.setLoading(filePath.value, false)
|
||||
})
|
||||
} else if (!newShow) {
|
||||
// 恢复文件管理的键盘快捷键
|
||||
window.$bus.emit('file:keyboard-resume')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
:title="$gettext('Edit - %{ file }', { file })"
|
||||
style="width: 60vw"
|
||||
size="huge"
|
||||
:title="$gettext('File Editor')"
|
||||
style="width: 90vw; height: 85vh"
|
||||
content-style="padding: 0; height: calc(85vh - 60px); display: flex; flex-direction: column;"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-flex>
|
||||
<n-button @click="handleRefresh"> {{ $gettext('Refresh') }} </n-button>
|
||||
<n-button type="primary" @click="handleSave"> {{ $gettext('Save') }} </n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<file-editor ref="editor" :path="file" :read-only="false" />
|
||||
<FileEditorView ref="editorRef" :initial-path="initialPath" />
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -906,8 +906,24 @@ const goToParentDir = () => {
|
||||
path.value = parentPath
|
||||
}
|
||||
|
||||
// 键盘快捷键暂停状态
|
||||
const keyboardPaused = ref(false)
|
||||
|
||||
const pauseKeyboard = () => {
|
||||
keyboardPaused.value = true
|
||||
}
|
||||
|
||||
const resumeKeyboard = () => {
|
||||
keyboardPaused.value = false
|
||||
}
|
||||
|
||||
// 键盘快捷键处理
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// 如果键盘监听被暂停(如编辑器打开时),不处理快捷键
|
||||
if (keyboardPaused.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果焦点在输入框中,不处理快捷键
|
||||
const target = event.target as HTMLElement
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
@@ -1116,6 +1132,8 @@ onMounted(() => {
|
||||
|
||||
window.$bus.on('file:search', handleFileSearch)
|
||||
window.$bus.on('file:refresh', refresh)
|
||||
window.$bus.on('file:keyboard-pause', pauseKeyboard)
|
||||
window.$bus.on('file:keyboard-resume', resumeKeyboard)
|
||||
|
||||
// 添加全局鼠标事件监听
|
||||
document.addEventListener('mousemove', onSelectionMove)
|
||||
@@ -1135,6 +1153,8 @@ onUnmounted(() => {
|
||||
// 移除事件监听
|
||||
window.$bus.off('file:search', handleFileSearch)
|
||||
window.$bus.off('file:refresh', refresh)
|
||||
window.$bus.off('file:keyboard-pause', pauseKeyboard)
|
||||
window.$bus.off('file:keyboard-resume', resumeKeyboard)
|
||||
document.removeEventListener('mousemove', onSelectionMove)
|
||||
document.removeEventListener('mouseup', onSelectionEnd)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
|
||||
Reference in New Issue
Block a user