feat: 文件管理编辑文件增加左侧目录树 (#5594)

Refs: #3624

#### What this PR does / why we need it?

#### Summary of your change

#### Please indicate you've done the following:

- [ ] Made sure tests are passing and test coverage is added if needed.
- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/).
- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed.
This commit is contained in:
2024-06-28 14:16:57 +08:00 committed by GitHub
parent 3adf1aebb8
commit 9cc4ec49ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 438 additions and 45 deletions

View File

@ -15,10 +15,12 @@ type UploadInfo struct {
}
type FileTree struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Children []FileTree `json:"children"`
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Extension string `json:"extension"`
Children []FileTree `json:"children"`
}
type DirSizeRes struct {

View File

@ -17,6 +17,7 @@ import (
"github.com/1Panel-dev/1Panel/backend/buserr"
"github.com/1Panel-dev/1Panel/backend/constant"
"golang.org/x/net/html/charset"
"golang.org/x/sys/unix"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
@ -51,6 +52,10 @@ type IFileService interface {
ReadLogByLine(req request.FileReadByLineReq) (*response.FileLineContent, error)
}
var filteredPaths = []string{
"/.1panel_clash",
}
func NewIFileService() IFileService {
return &FileService{}
}
@ -100,27 +105,78 @@ func (f *FileService) SearchUploadWithPage(req request.SearchUploadWithPage) (in
func (f *FileService) GetFileTree(op request.FileOption) ([]response.FileTree, error) {
var treeArray []response.FileTree
if _, err := os.Stat(op.Path); err != nil && os.IsNotExist(err) {
return treeArray, nil
}
info, err := files.NewFileInfo(op.FileOption)
if err != nil {
return nil, err
}
node := response.FileTree{
ID: common.GetUuid(),
Name: info.Name,
Path: info.Path,
ID: common.GetUuid(),
Name: info.Name,
Path: info.Path,
IsDir: info.IsDir,
Extension: info.Extension,
}
for _, v := range info.Items {
if v.IsDir {
node.Children = append(node.Children, response.FileTree{
ID: common.GetUuid(),
Name: v.Name,
Path: v.Path,
})
}
err = f.buildFileTree(&node, info.Items, op, 2)
if err != nil {
return nil, err
}
return append(treeArray, node), nil
}
func shouldFilterPath(path string) bool {
cleanedPath := filepath.Clean(path)
for _, filteredPath := range filteredPaths {
cleanedFilteredPath := filepath.Clean(filteredPath)
if cleanedFilteredPath == cleanedPath || strings.HasPrefix(cleanedPath, cleanedFilteredPath+"/") {
return true
}
}
return false
}
// 递归构建文件树(只取当前目录以及当前目录下的第一层子节点)
func (f *FileService) buildFileTree(node *response.FileTree, items []*files.FileInfo, op request.FileOption, level int) error {
for _, v := range items {
if shouldFilterPath(v.Path) {
global.LOG.Info("File Tree: Skipping %s due to filter\n", v.Path)
continue
}
childNode := response.FileTree{
ID: common.GetUuid(),
Name: v.Name,
Path: v.Path,
IsDir: v.IsDir,
Extension: v.Extension,
}
if level > 1 && v.IsDir {
if err := f.buildChildNode(&childNode, v, op, level); err != nil {
return err
}
}
node.Children = append(node.Children, childNode)
}
return nil
}
func (f *FileService) buildChildNode(childNode *response.FileTree, fileInfo *files.FileInfo, op request.FileOption, level int) error {
op.Path = fileInfo.Path
subInfo, err := files.NewFileInfo(op.FileOption)
if err != nil {
if os.IsPermission(err) || errors.Is(err, unix.EACCES) {
global.LOG.Info("File Tree: Skipping %s due to permission denied\n", fileInfo.Path)
return nil
}
global.LOG.Errorf("File Tree: Skipping %s due to error: %s\n", fileInfo.Path, err.Error())
return nil
}
return f.buildFileTree(childNode, subInfo.Items, op, level-1)
}
func (f *FileService) Create(op request.FileCreate) error {
if files.IsInvalidChar(op.Path) {
return buserr.New("ErrInvalidChar")

View File

@ -93,6 +93,7 @@ const message = {
user: 'User',
title: 'Title',
port: 'Port',
forward: 'Forward',
protocol: 'Protocol',
tableSetting: 'Table setting',
refreshRate: 'Rate',
@ -139,6 +140,8 @@ const message = {
remove: 'Remove',
backupHelper: 'The current operation will back up {0}. Do you want to proceed?',
recoverHelper: 'Restoring from {0} file. This operation is irreversible. Do you want to continue?',
refreshSuccess: 'Refresh successful',
rootInfoErr: "It's already the root directory",
},
login: {
username: 'UserName',
@ -1220,6 +1223,7 @@ const message = {
refresh: 'Refresh',
openWithVscode: 'Open with VS Code',
vscodeHelper: 'Please make sure that VS Code is installed locally and the SSH Remote plugin is configured',
up: 'Go back',
},
ssh: {
autoStart: 'Auto Start',

View File

@ -92,6 +92,7 @@ const message = {
user: '用戶',
title: '標題',
port: '端口',
forward: '轉發',
protocol: '協議',
tableSetting: '列表設置',
refreshRate: '刷新頻率',
@ -139,6 +140,8 @@ const message = {
remove: '移出',
backupHelper: '當前操作將對 {0} 進行備份是否繼續',
recoverHelper: '將從 {0} 文件進行恢復該操作不可回滾是否繼續',
refreshSuccess: '重繪成功',
rootInfoErr: '已經是根目錄了',
},
login: {
username: '用戶名',
@ -1150,12 +1153,13 @@ const message = {
'下載時忽略不可信證書可能導致數據洩露或篡改請謹慎使用此選項僅在信任下載源的情況下啟用',
uploadOverLimit: '文件數量超過 1000 請壓縮後上傳',
clashDitNotSupport: '檔名禁止包含 .1panel_clash',
clashDleteAlert: '回收站資料夾不能刪除',
clashDeleteAlert: '回收站資料夾不能刪除',
clashOpenAlert: '回收站目錄請點選回收站按鈕開啟',
right: '前進',
back: '後退',
top: '返回上一層',
refresh: '重新整理',
up: '上一層',
openWithVscode: 'VS Code 打開',
vscodeHelper: '請確保本地已安裝 VS Code 並配置了 SSH Remote 插件',
},

View File

@ -140,6 +140,8 @@ const message = {
remove: '移出',
backupHelper: '当前操作将对 {0} 进行备份是否继续',
recoverHelper: '将从 {0} 文件进行恢复该操作不可回滚是否继续',
refreshSuccess: '刷新成功',
rootInfoErr: '已经是根目录了',
},
login: {
username: '用户名',
@ -1152,12 +1154,13 @@ const message = {
'下载时忽略不可信证书可能导致数据泄露或篡改请谨慎使用此选项仅在信任下载源的情况下启用',
uploadOverLimit: '文件数量超过 1000请压缩后上传',
clashDitNotSupport: '文件名禁止包含 .1panel_clash',
clashDleteAlert: '回收站文件夹不能删除',
clashDeleteAlert: '回收站文件夹不能删除',
clashOpenAlert: '回收站目录请点击回收站按钮打开',
right: '前进',
back: '后退',
top: '返回上一级',
refresh: '刷新',
up: '上一级',
openWithVscode: 'VS Code 打开',
vscodeHelper: '请确保本地已安装 VS Code 并配置了 SSH Remote 插件',
},

View File

@ -1,7 +1,7 @@
<template>
<el-dialog
v-model="open"
:title="$t('commons.button.edit') + ' - ' + fileName"
:show-close="false"
:before-close="handleClose"
destroy-on-close
width="70%"
@ -9,10 +9,18 @@
:top="'5vh'"
:fullscreen="isFullscreen"
>
<template #header>
<div class="flex items-center justify-between">
<span>{{ $t('commons.button.edit') + ' - ' + form.path }}</span>
<el-space alignment="center" :size="10" class="dialog-header-icon">
<el-tooltip :content="loadTooltip()" placement="top">
<el-icon @click="toggleFullscreen"><FullScreen /></el-icon>
</el-tooltip>
<el-icon @click="handleClose" size="20"><Close /></el-icon>
</el-space>
</div>
</template>
<el-form :inline="true" :model="config" class="mt-1.5">
<el-tooltip :content="loadTooltip()" placement="top">
<el-button @click="toggleFullscreen" class="fullScreen" icon="FullScreen" plain></el-button>
</el-tooltip>
<el-form-item :label="$t('file.theme')">
<el-select v-model="config.theme" @change="changeTheme()" class="p-w-200">
<el-option v-for="item in themes" :key="item.label" :value="item.value" :label="item.label" />
@ -36,7 +44,90 @@
</el-form-item>
</el-form>
<div v-loading="loading">
<div id="codeBox" style="height: 55vh"></div>
<div class="flex">
<div class="sm:w-48 w-1/3 monaco-editor-background tree-container" v-if="isShow">
<div class="flex items-center justify-between pl-1 sm:pr-4 pr-1 pt-1">
<el-tooltip :content="$t('file.top')" placement="top">
<el-text size="small" @click="getUpData()" class="cursor-pointer">
<el-icon>
<Top />
</el-icon>
<span class="sm:inline hidden pl-1">{{ $t('file.up') }}</span>
</el-text>
</el-tooltip>
<el-tooltip :content="$t('file.refresh')" placement="top">
<el-text size="small" @click="getRefresh(directoryPath)" class="cursor-pointer">
<el-icon>
<Refresh />
</el-icon>
<span class="sm:inline hidden pl-1">{{ $t('file.refresh') }}</span>
</el-text>
</el-tooltip>
</div>
<el-divider class="!my-1" />
<el-tree-v2
ref="treeRef"
:data="treeData"
:props="treeProps"
@node-expand="handleNodeExpand"
class="monaco-editor-tree monaco-editor-background"
:height="treeHeight"
:indent="6"
:item-size="24"
highlight-current
>
<template #default="{ node, data }">
<!-- 目录 -->
<span v-if="data.isDir" style="display: inline-flex; align-items: center">
<svg-icon className="table-icon" iconName="p-file-folder"></svg-icon>
<small :title="node.label">{{ node.label }}</small>
</span>
<!-- 文档 -->
<span
v-else
style="display: inline-flex; align-items: center"
@click="getContent(data.path, data.extension)"
>
<svg-icon className="table-icon" :iconName="getIconName(data.extension)"></svg-icon>
<small :title="node.label" class="min-w-32">{{ node.label }}</small>
</span>
</template>
</el-tree-v2>
</div>
<div class="relative">
<el-divider
v-if="isShow"
direction="vertical"
:style="{ height: codeHeight }"
class="!m-0 p-0"
:class="isShow ? 'opacity-100' : 'opacity-0'"
></el-divider>
<el-icon
v-if="isShow"
class="cursor-pointer absolute bg-gray-100 py-2 rounded-l-sm block top-1/3 -left-[9px]"
size="9"
@click="toggleShow"
>
<DArrowLeft />
</el-icon>
<el-icon
v-else
class="cursor-pointer absolute bg-gray-100 py-2 rounded-r-sm block top-1/3 right-[7px]"
size="9"
@click="toggleShow"
>
<DArrowRight />
</el-icon>
</div>
<div
ref="codeBox"
id="codeBox"
:style="{ height: codeHeight }"
class="flex-1 sm:w-4/5 w-2/3 relative"
></div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
@ -48,11 +139,11 @@
</template>
<script lang="ts" setup>
import { SaveFileContent } from '@/api/modules/files';
import { GetFileContent, GetFilesTree, SaveFileContent } from '@/api/modules/files';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { MsgError, MsgInfo, MsgSuccess } from '@/utils/message';
import * as monaco from 'monaco-editor';
import { nextTick, onBeforeUnmount, reactive, ref } from 'vue';
import { nextTick, onBeforeUnmount, reactive, ref, onMounted } from 'vue';
import { Languages } from '@/global/mimetype';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
@ -60,6 +151,14 @@ import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import { ElTreeV2 } from 'element-plus';
import { ResultData } from '@/api/interface';
import { File } from '@/api/interface/file';
import { getIcon } from '@/utils/util';
import { TreeKey, TreeNodeData } from 'element-plus/es/components/tree-v2/src/types';
import { Top, Refresh, DArrowLeft, DArrowRight, FullScreen, Close } from '@element-plus/icons-vue';
import { loadBaseDir } from '@/api/modules/setting';
let editor: monaco.editor.IStandaloneCodeEditor | undefined;
self.MonacoEnvironment = {
@ -85,6 +184,7 @@ interface EditProps {
content: string;
path: string;
name: string;
extension: string;
}
interface EditorConfig {
@ -94,11 +194,37 @@ interface EditorConfig {
wordWrap: WordWrapOptions;
}
interface TreeNode {
key: TreeKey;
level: number;
parent?: TreeNode;
children?: File.FileTree[];
data: TreeNodeData;
disabled?: boolean;
name?: string;
isLeaf?: boolean;
}
const open = ref(false);
const loading = ref(false);
const fileName = ref('');
const codeThemeKey = 'code-theme';
const warpKey = 'code-warp';
const directoryPath = ref('');
const fileExtension = ref('');
const baseDir = ref();
const treeData = ref([]);
const codeBox = ref();
const defaultHeight = ref(55);
const fullScreenHeight = ref(80);
const treeHeight = ref(0);
const codeHeight = ref('55vh');
const codeReq = reactive({ path: '', expand: false, page: 1, pageSize: 100 });
const isShow = ref(true);
const toggleShow = () => {
isShow.value = !isShow.value;
};
type WordWrapOptions = 'off' | 'on' | 'wordWrapColumn' | 'bounded';
@ -155,9 +281,29 @@ const handleClose = () => {
const loadTooltip = () => {
return i18n.global.t('commons.button.' + (isFullscreen.value ? 'quitFullscreen' : 'fullscreen'));
};
function toggleFullscreen() {
onMounted(() => {
loadPath();
updateHeights();
window.addEventListener('resize', updateHeights);
});
const updateHeights = () => {
const vh = window.innerHeight / 100;
if (isFullscreen.value) {
treeHeight.value = fullScreenHeight.value * vh - 31;
codeHeight.value = `${fullScreenHeight.value}vh`;
} else {
treeHeight.value = defaultHeight.value * vh - 31;
codeHeight.value = `${defaultHeight.value}vh`;
}
};
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
}
updateHeights();
};
const changeLanguage = () => {
monaco.editor.setModelLanguage(editor.getModel(), config.language);
};
@ -183,8 +329,7 @@ const initEditor = () => {
editor.dispose();
}
nextTick(() => {
const codeBox = document.getElementById('codeBox');
editor = monaco.editor.create(codeBox as HTMLElement, {
editor = monaco.editor.create(codeBox.value as HTMLElement, {
theme: config.theme,
value: form.value.content,
readOnly: false,
@ -195,6 +340,10 @@ const initEditor = () => {
overviewRulerBorder: false,
wordWrap: config.wordWrap,
});
if (editor.getModel().getValue() === '') {
let defaultContent = '\n\n\n\n';
editor.getModel().setValue(defaultContent);
}
editor.onDidChangeModelContent(() => {
if (editor) {
form.value.content = editor.getValue();
@ -231,38 +380,212 @@ const saveContent = (closePage: boolean) => {
const acceptParams = (props: EditProps) => {
form.value.content = props.content;
form.value.path = props.path;
config.language = props.language;
directoryPath.value = getDirectoryPath(props.path);
fileExtension.value = props.extension;
fileName.value = props.name;
config.language = props.language;
config.eol = monaco.editor.EndOfLineSequence.LF;
config.theme = localStorage.getItem(codeThemeKey) || 'vs-dark';
config.wordWrap = (localStorage.getItem(warpKey) as WordWrapOptions) || 'on';
open.value = true;
};
const getIconName = (extension: string) => getIcon(extension);
const loadPath = async () => {
const pathRes = await loadBaseDir();
baseDir.value = pathRes.data;
};
const getDirectoryPath = (filePath: string) => {
if (!filePath) {
return baseDir.value;
}
const lastSlashIndex = filePath.lastIndexOf('/');
if (lastSlashIndex === -1) {
return baseDir.value;
}
const directoryPath = filePath.substring(0, lastSlashIndex);
if (directoryPath === '' || directoryPath === '.' || directoryPath === '/') {
return baseDir.value;
}
return directoryPath;
};
const onOpen = () => {
initEditor();
search(directoryPath.value).then((res) => {
handleSearchResult(res);
});
};
const handleSearchResult = (res: ResultData<File.FileTree[]>) => {
if (res.data.length > 0 && res.data[0].children) {
treeData.value = res.data[0].children.map((item) => ({
...item,
children: item.isDir ? item.children || [] : undefined,
}));
} else {
treeData.value = [];
}
};
const getRefresh = (path: string) => {
loading.value = true;
try {
search(path).then((res) => {
treeData.value = res.data[0].children;
loadedNodes.value = new Set();
});
} finally {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.refreshSuccess'));
}
};
const getContent = (path: string, extension: string) => {
if (form.value.path !== path) {
codeReq.path = path;
codeReq.expand = true;
if (extension != '') {
Languages.forEach((language) => {
const ext = extension.substring(1);
if (language.value.indexOf(ext) > -1) {
config.language = language.label;
}
});
}
GetFileContent(codeReq)
.then((res) => {
form.value.content = res.data.content;
form.value.path = res.data.path;
fileExtension.value = res.data.extension;
fileName.value = res.data.name;
initEditor();
})
.catch(() => {});
}
};
const initTreeData = () => ({
path: '/',
expand: true,
showHidden: true,
page: 1,
pageSize: 1000,
search: '',
containSub: true,
dir: false,
sortBy: 'name',
sortOrder: 'ascending',
});
let req = reactive(initTreeData());
const loadedNodes = ref(new Set());
const search = async (path: string) => {
req.path = path;
if (req.search != '') {
req.sortBy = 'name';
req.sortOrder = 'ascending';
}
return await GetFilesTree(req);
};
const getUpData = async () => {
if ('/' === directoryPath.value) {
MsgInfo(i18n.global.t('commons.msg.rootInfoErr'));
return;
}
let pathParts = directoryPath.value.split('/');
pathParts.pop();
let newPath = pathParts.join('/') || '/';
try {
const response = await search(newPath);
treeData.value = response.data[0]?.children || [];
loadedNodes.value = new Set();
} catch (error) {
MsgError(i18n.global.t('commons.msg.notRecords'));
} finally {
directoryPath.value = newPath;
}
};
const treeRef = ref<InstanceType<typeof ElTreeV2>>();
const treeProps = {
value: 'id',
label: 'name',
children: 'children',
};
const handleNodeExpand = async (node: any, data: TreeNode) => {
if (!data.data.isDir || loadedNodes.value.has(data.data.path)) {
return;
}
try {
const response = await search(node.path);
const newTreeData = JSON.parse(JSON.stringify(treeData.value));
if (response.data.length > 0 && response.data[0].children) {
data.children = response.data[0].children;
loadedNodes.value.add(data.data.path);
updateNodeChildren(newTreeData, data.data.path, response.data[0].children);
} else {
data.children = [];
}
treeData.value = newTreeData;
} catch (error) {
MsgError(i18n.global.t('commons.msg.notRecords'));
}
};
// children
const updateNodeChildren = (nodes: any[], path: any, newChildren: File.FileTree[]) => {
const updateNode = (nodes: string | any[]) => {
for (const element of nodes) {
if (element.path === path) {
element.children = newChildren;
break;
}
if (element.children && element.children.length) {
updateNode(element.children);
}
}
};
updateNode(nodes);
};
onBeforeUnmount(() => {
if (editor) {
editor.dispose();
}
window.removeEventListener('resize', updateHeights);
});
defineExpose({ acceptParams });
</script>
<style>
<style scoped lang="scss">
.dialog-top {
top: 0;
}
.fullScreen {
background-color: transparent;
border: none;
position: absolute;
right: 50px;
font-weight: 600;
font-size: 14px;
.dialog-header-icon {
color: var(--el-color-info);
}
.monaco-editor-tree {
color: var(--el-color-primary) !important;
}
.tree-widget {
background-color: var(--el-button--primary);
}
</style>

View File

@ -89,7 +89,7 @@ const onConfirm = () => {
const pros = [];
for (const s of files.value) {
if (s['path'].indexOf('.1panel_clash') > -1) {
MsgWarning(i18n.global.t('file.clashDleteAlert'));
MsgWarning(i18n.global.t('file.clashDeleteAlert'));
return;
}
pros.push(DeleteFile({ path: s['path'], isDir: s['isDir'], forceDelete: forceDelete.value }));

View File

@ -386,7 +386,7 @@ let pointer = -1;
const fileCreate = reactive({ path: '/', isDir: false, mode: 0o755 });
const fileCompress = reactive({ files: [''], name: '', dst: '', operate: 'compress' });
const fileDeCompress = reactive({ path: '', name: '', dst: '', mimeType: '' });
const fileEdit = reactive({ content: '', path: '', name: '', language: 'plaintext' });
const fileEdit = reactive({ content: '', path: '', name: '', language: 'plaintext', extension: '' });
const codeReq = reactive({ path: '', expand: false, page: 1, pageSize: 100 });
const fileUpload = reactive({ path: '' });
const fileRename = reactive({ path: '', oldName: '' });
@ -698,6 +698,7 @@ const openCodeEditor = (path: string, extension: string) => {
fileEdit.content = res.data.content;
fileEdit.path = res.data.path;
fileEdit.name = res.data.name;
fileEdit.extension = res.data.extension;
codeEditorRef.value.acceptParams(fileEdit);
})
@ -894,6 +895,10 @@ const buttons = [
label: i18n.global.t('file.copyDir'),
click: copyDir,
},
{
label: i18n.global.t('file.openWithVscode'),
click: openWithVSCode,
},
{
label: i18n.global.t('commons.button.delete'),
disabled: (row: File.File) => {
@ -905,10 +910,6 @@ const buttons = [
label: i18n.global.t('file.info'),
click: openDetail,
},
{
label: i18n.global.t('file.openWithVscode'),
click: openWithVSCode,
},
];
onMounted(() => {