English | 简体中文
- Provide FileTree with VSCode style.
- Use async api for vue events
- A hook for global float message box.
- vue3 v3.3.4+ or nuxt3 v3.0+
- monaco-editor v0.44.0+
- [Recommended]pnpm package manager
pnpm add monaco-tree-editor
#or
npm i monaco-tree-editor
{root}/node_modules/monaco-tree-editor/monaco-tree-editor-statics
=> {root}/public/monaco-tree-editor-statics
mock-server.ts
import { type Files } from 'monaco-tree-editor'
const fileSeparator = '\\'
let responseFiles: Files = {
'F:\\test_project\\test.html': {
isFile: true,
content: '<html><body><h1>Hello World!</h1></body></html>',
},
'F:\\test_project\\components': {
isFolder: true,
},
'F:\\test_project\\index.ts': {
isFile: true,
content: 'console.log("hello world")',
},
'F:\\test_project\\api\\TestApi.ts': {
isFile: true,
content: 'console.log("hello world")',
},
'F:\\test_project\\dto\\TestDto.ts': {
isFile: true,
content: 'console.log("hello world")',
},
}
// mock delay to test robustness
export const delay = async (maxMs = 3000) => {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, Math.random() * maxMs)
})
}
export const fetchFiles = async () => {
await delay(1000)
return await JSON.parse(JSON.stringify(responseFiles))
}
export const createOrSaveFile = async (path: string, content: string) => {
await delay()
if (responseFiles[path]) {
if (!responseFiles[path].isFile) {
throw new Error(`save file:[ ${path} ] is not a file!`)
}
responseFiles[path].content = content
} else {
responseFiles[path] = {
isFile: true,
content,
}
}
}
export const newFile = async (path: string) => {
await delay()
if (responseFiles[path]) {
throw new Error(`new file: [ ${path} ] already exists!`)
}
responseFiles[path] = {
isFile: true,
content: '',
}
}
export const newFolder = async (path: string) => {
await delay()
if (responseFiles[path]) {
throw new Error(`new folder: [ ${path} ] already exists!`)
}
responseFiles[path] = {
isFolder: true,
}
}
export const rename = async (path: string, newPath: string) => {
await delay()
if (!responseFiles[path]) {
throw new Error(`rename: source file/folder name [ ${path} ] not exists!`)
} else if (responseFiles[newPath]) {
throw new Error(`rename: target file/folder name [ ${newPath} ] already exists!`)
}
responseFiles[newPath] = responseFiles[path]
if (path !== newPath) {
delete responseFiles[path]
}
return true
}
export const deleteFile = async (path: string) => {
await delay()
if (!responseFiles[path]) {
throw new Error(`delete: file name [ ${path} ] not exists!`)
}
delete responseFiles[path]
return true
}
import { Editor as MonacoTreeEditor, useMonaco, type Files } from 'monaco-tree-editor'
import 'moanco-tree-editor/index.css'
import { ref } from 'vue'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import * as server from './mock-server'
// ================ init monaco-tree-editor ================
window.MonacoEnvironment = {
getWorker: function (_moduleId, label: string) {
if (label === 'json') {
return new jsonWorker()
} else if (label === 'ts' || label === 'typescript') {
return new tsWorker()
} else if (label === 'html' || label === 'handlebars' || label === 'razor') {
return new htmlWorker()
} else if (label === 'css' || label === 'scss' || label === 'less') {
return new cssWorker()
}
return new editorWorker()
},
globalAPI: true,
}
let monacoStore
// mock delay to test robustness
server.delay().then(() => {
monacoStore = useMonaco(monaco)
})
// ================ callback =================
/*
Whaterver the server's file name is,
the component will take the longest common prefix,
and the path in the callback method will be concatenated with the original path
For example:
const serverFiles = {
'F:\\test_project\\index.ts': {...},
'F:\\test_project\\components\\template.ts': {...}
}
In component, it will be converted to:
const serverFiles = {
'/index.ts': {...},
'/components/template.ts': {...},
}
And in your callback functions:
const handleSaveFile = (path: string, resolve: () => void, reject: (msg?: string) => void) => {
console.log(path) // will print 'F:\\test_project\\index.ts'
}
*/
const files = ref<Files>()
const handleReload = (resolve: () => void, reject: (msg?: string) => void) => {
server
.fetchFiles()
.then((response) => {
files.value = response
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
const handleSaveFile = (path: string, content: string, resolve: () => void, reject: (msg?: string) => void) => {
server
.createOrSaveFile(path, content)
.then((_response) => {
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
const handleDeleteFile = (path: string, resolve: () => void, reject: (msg?: string) => void) => {
server
.deleteFile(path)
.then((_response) => {
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
const handleDeleteFolder = (path: string, resolve: () => void, reject: (msg?: string) => void) => {
reject('Operation of delete folder is not supported!')
}
const handleNewFile = (path: string, resolve: Function, reject: Function) => {
server
.newFile(path)
.then((_response) => {
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
const handleNewFolder = (path: string, resolve: Function, reject: Function) => {
server
.newFolder(path)
.then((_response) => {
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
const handleRename = (path: string, newPath: string, resolve: () => void, reject: (msg?: string) => void) => {
server
.rename(path, newPath)
.then((_response) => {
resolve()
})
.catch((e: Error) => {
reject(e.message)
})
}
<template>
<MonacoTreeEditor
:font-size="14"
:files="files"
:sider-min-width="240"
filelist-title="FileList"
@reload="handleReload"
@new-file="handleNewFile"
@new-folder="handleNewFolder"
@save-file="handleSaveFile"
@delete-file="handleDeleteFile"
@delete-folder="handleDeleteFolder"
@rename-file="handleRename"
@rename-folder="handleRename"
></MonacoTreeEditor>
</template>
import { useMessage } from 'monaco-tree-editor'
import { onMounted } from 'vue'
const messageStore = useMessage()
onMounted(() => {
const id = messageStore.action.info({
content: 'testing..',
loading: true,
})
setTimeout(() => {
messageStore.action.close(id)
messageStore.action.success({
content: 'Hello Editor',
closeable: true,
timeoutMs: 15000,
textTip: 'testing successed!',
})
}, 5000)
})
- unrecommended, this api will be deprecated
- in the future, a new interactive shortcut key setting feature will be integrated
import { useHotkey } from 'monaco-tree-editor'
const hotkeyStore = useHotkey()
// Trigger when the focus is on the root component
hotkeyStore.listen('root', (event: KeyboardEvent) => {})
// Trigger when the focus is in the editor
hotkeyStore.listen('editor', (event: KeyboardEvent) => {
if (event.ctrlKey && !event.shiftKey && !event.altKey && (event.key === 's' || event.key === 'S')) {
// do something...
}
})
import { ref } from 'vue'
// ================ custom menu =================
/**
* Custom fileMenu and folderMenu Will insert into the context menu of sider file list
*/
const fileMenu = ref([
{ label: 'Custom Selection 1', value: 'any type that not null' },
{ label: 'Custom Selection 2', value: 2 },
{ label: 'Custom Selection 3', value: { id: 3, decription: 'value could be any type without null or undefined' } },
])
const folderMenu = ref([{ label: 'backup', value: 'backupFolder' }])
/*
* Click the settings icon in the lower left corner to display custom menus
*/
const settingsMenu = ref([
{
label: 'exit',
handler: () => {
alert('exit')
},
},
])
const handleContextMenuSelect = (path: string, item: { label: string | ComputedRef<string>; value: string }) => {
console.warn('path: ' + path + '\nitem: ' + item)
}
<template>
<MonacoTreeEditor
:file-menu="fileMenu"
:folder-menu="folderMenu"
:settings-menu="settingsMenu"
@contextmenu-select="handleContextMenuSelect"
></MonacoTreeEditor>
</template>
language currently has two options: en-US
and zh-CN
.
<!--
en-US: English (Default)
zh-CN: 简体中文
-->
<MonacoTreeEditor language="en-US"></MonacoTreeEditor>
theme currently has two options: dark
and light
.
<!--
dark: dark theme
light: light theme
-->
<MonacoTreeEditor theme="dark"></MonacoTreeEditor>
/*
* For example, When the user drags a file to the editor, the file will be imported into the editor
*/
const handleDragInEditor = (srcPath: string, targetPath: string, type: 'file' | 'folder') => {
if (!targetPath.endsWith('.ts') && !srcPath.endsWith('.js')) {
return
}
const editor = monacoStore.action.getEditor()
const lineIndex = editor.getPosition()?.lineNumber!
let str = 'import "' + _relativePathFrom(srcPath, targetPath) + '"'
editor.executeEdits('drop', [{ range: new monaco.Range(lineIndex, 0, lineIndex, 0), text: str }])
}
function _longestCommonPrefix(strs: string[]): string {
if (!strs.length) return ''
let [a, ...b] = strs
let result = ''
for (let i = 0; i < a.length; i++) {
let flag = b.every((item) => item[i] === a[i])
if (flag) result += a[i]
else break
}
return result
}
// getRelativePath
const _relativePathFrom = (returnPath: string, fromPath: string): string => {
const prefix = _longestCommonPrefix([returnPath, fromPath])
returnPath = returnPath.replace(prefix, '').replace(/\\/g, '/')
fromPath = fromPath.replace(prefix, '').replace(/\\/g, '/')
const fromPathArr = fromPath.split('/')
let relativePath = ''
if (fromPathArr.length === 1) {
relativePath = './'
} else {
for (let i = fromPathArr.length - 2; i >= 0; i--) {
relativePath += '../'
}
}
return (relativePath += returnPath)
}
<template>
<MonacoTreeEditor @drag-in-editor="handleDragInEditor"></MonacoTreeEditor>
</template>