Commit ecb17934 authored by tom's avatar tom

file tree basic implementation

parent 38b620c6
...@@ -5,6 +5,7 @@ import { KEY_WORDS } from '../utils'; ...@@ -5,6 +5,7 @@ import { KEY_WORDS } from '../utils';
export function monaco(): CspDev.DirectiveDescriptor { export function monaco(): CspDev.DirectiveDescriptor {
return { return {
'script-src': [ 'script-src': [
// todo_tom try to remove this
KEY_WORDS.BLOB, KEY_WORDS.BLOB,
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/loader.js', 'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/loader.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/editor/editor.main.js', 'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/editor/editor.main.js',
......
...@@ -4,8 +4,8 @@ import React from 'react'; ...@@ -4,8 +4,8 @@ import React from 'react';
import type { SmartContract } from 'types/api/contract'; import type { SmartContract } from 'types/api/contract';
import CodeEditorMonaco from 'ui/shared/CodeEditorMonaco';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import CodeEditor from 'ui/shared/monaco/CodeEditor';
interface Props { interface Props {
data: string; data: string;
...@@ -36,7 +36,10 @@ const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, addi ...@@ -36,7 +36,10 @@ const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, addi
</Tooltip> </Tooltip>
) : null; ) : null;
const code = [ { file_path: filePath || 'index.sol', source_code: data }, ...(additionalSource || []) ]; const editorData = React.useMemo(() => {
const defaultName = isViper ? 'index.vy' : 'index.sol';
return [ { file_path: filePath || defaultName, source_code: data }, ...(additionalSource || []) ];
}, [ additionalSource, data, filePath, isViper ]);
return ( return (
<section> <section>
...@@ -44,7 +47,7 @@ const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, addi ...@@ -44,7 +47,7 @@ const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, addi
{ heading } { heading }
{ diagramLink } { diagramLink }
</Flex> </Flex>
<CodeEditorMonaco data={ code }/> <CodeEditor data={ editorData }/>
</section> </section>
); );
}; };
......
import { Box, useColorMode } from '@chakra-ui/react'; import { Box, useColorMode, Flex } from '@chakra-ui/react';
import Editor from '@monaco-editor/react'; import MonacoEditor from '@monaco-editor/react';
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import React from 'react'; import React from 'react';
import type { File } from './types';
import CodeEditorFileExplorer from './CodeEditorFileExplorer';
import * as themes from './utils/themes';
export type Monaco = typeof monaco; export type Monaco = typeof monaco;
interface Props { interface Props {
data: Array<{ file_path: string; source_code: string }>; data: Array<File>;
} }
const CodeEditorMonaco = ({ data }: Props) => { const CodeEditor = ({ data }: Props) => {
const instance = React.useRef<Monaco>(); const instance = React.useRef<Monaco>();
const [ index ] = React.useState(0);
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
...@@ -20,40 +27,30 @@ const CodeEditorMonaco = ({ data }: Props) => { ...@@ -20,40 +27,30 @@ const CodeEditorMonaco = ({ data }: Props) => {
const handleEditorDidMount = React.useCallback((editor: monaco.editor.IStandaloneCodeEditor, monaco: Monaco) => { const handleEditorDidMount = React.useCallback((editor: monaco.editor.IStandaloneCodeEditor, monaco: Monaco) => {
instance.current = monaco; instance.current = monaco;
monaco.editor.defineTheme('blockscout-light', { monaco.editor.defineTheme('blockscout-light', themes.light);
base: 'vs', monaco.editor.defineTheme('blockscout-dark', themes.dark);
inherit: true,
rules: [],
colors: {
'editor.background': '#f5f5f6',
},
});
monaco.editor.defineTheme('blockscout-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#1a1b1b',
},
});
monaco.editor.setTheme(colorMode === 'light' ? 'blockscout-light' : 'blockscout-dark'); monaco.editor.setTheme(colorMode === 'light' ? 'blockscout-light' : 'blockscout-dark');
// componentDidMount // componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]); }, [ ]);
return ( return (
<Box overflow="hidden" borderRadius="md"> <Flex overflow="hidden" borderRadius="md">
<Editor <Box flexGrow={ 1 }>
height="500px" <MonacoEditor
language="sol" height="500px"
defaultValue={ data[0].source_code } language="sol"
options={{ readOnly: true, inlayHints: { enabled: 'off' }, minimap: { enabled: false } }} path={ data[index].file_path }
onMount={ handleEditorDidMount } defaultValue={ data[index].source_code }
/> options={{ readOnly: true, inlayHints: { enabled: 'off' }, minimap: { enabled: false } }}
</Box> onMount={ handleEditorDidMount }
/>
</Box>
<Box w="250px" flexShrink={ 0 } bgColor="lightpink" fontSize="sm">
<CodeEditorFileExplorer data={ data }/>
</Box>
</Flex>
); );
}; };
export default CodeEditorMonaco; export default React.memo(CodeEditor);
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { File } from './types';
import CodeEditorFileTree from './CodeEditorFileTree';
import composeFileTree from './utils/composeFileTree';
interface Props {
data: Array<File>;
}
const CodeEditorFileExplorer = ({ data }: Props) => {
const tree = React.useMemo(() => {
return composeFileTree(data);
}, [ data ]);
return (
<Box>
<CodeEditorFileTree tree={ tree }/>
</Box>
);
};
export default React.memo(CodeEditorFileExplorer);
import type { ChakraProps } from '@chakra-ui/react';
import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel } from '@chakra-ui/react';
import React from 'react';
import type { FileTree } from './types';
interface Props {
tree: FileTree;
level?: number;
}
const CodeEditorFileTree = ({ tree, level = 0 }: Props) => {
const itemProps: ChakraProps = {
ml: level ? 4 : 0,
borderWidth: '0px',
_last: {
borderBottomWidth: '0px',
},
cursor: 'pointer',
};
return (
<Accordion allowMultiple>
{
tree.map((leaf, index) => {
if ('children' in leaf) {
return (
<AccordionItem key={ index } { ...itemProps }>
<AccordionButton p={ 0 } _hover={{ bgColor: 'inherit' }} fontSize="sm">
<AccordionIcon/>
<span>{ leaf.name }</span>
</AccordionButton>
<AccordionPanel p={ 0 }>
<CodeEditorFileTree tree={ leaf.children } level={ level + 1 }/>
</AccordionPanel>
</AccordionItem>
);
}
return (
<AccordionItem key={ index } { ...itemProps }>
{ leaf.name }
</AccordionItem>
);
})
}
</Accordion>
);
};
export default React.memo(CodeEditorFileTree);
export interface File {
file_path: string;
source_code: string;
}
export interface FileTreeFile extends File {
name: string;
}
export interface FileTreeFolder {
name: string;
children: Array<FileTreeFile | FileTreeFolder>;
}
export type FileTree = Array<FileTreeFile | FileTreeFolder>;
import composeFileTree from './composeFileTree';
const files = [
{
file_path: 'index.sol',
source_code: 'zero',
},
{
file_path: 'contracts/Zeta.eth.sol',
source_code: 'one',
},
{
file_path: '/_openzeppelin/contracts/utils/Context.sol',
source_code: 'two',
},
{
file_path: '/_openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol',
source_code: 'three',
},
{
file_path: '/_openzeppelin/contracts/token/ERC20/IERC20.sol',
source_code: 'four',
},
{
file_path: '/_openzeppelin/contracts/token/ERC20/ERC20.sol',
source_code: 'five',
},
];
test('builds correct file tree', () => {
const result = composeFileTree(files);
expect(result).toMatchInlineSnapshot(`
[
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"file_path": "/_openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol",
"name": "IERC20Metadata.sol",
"source_code": "three",
},
],
"name": "extensions",
},
{
"file_path": "/_openzeppelin/contracts/token/ERC20/ERC20.sol",
"name": "ERC20.sol",
"source_code": "five",
},
{
"file_path": "/_openzeppelin/contracts/token/ERC20/IERC20.sol",
"name": "IERC20.sol",
"source_code": "four",
},
],
"name": "ERC20",
},
],
"name": "token",
},
{
"children": [
{
"file_path": "/_openzeppelin/contracts/utils/Context.sol",
"name": "Context.sol",
"source_code": "two",
},
],
"name": "utils",
},
],
"name": "contracts",
},
],
"name": "_openzeppelin",
},
{
"children": [
{
"file_path": "contracts/Zeta.eth.sol",
"name": "Zeta.eth.sol",
"source_code": "one",
},
],
"name": "contracts",
},
{
"file_path": "index.sol",
"name": "index.sol",
"source_code": "zero",
},
]
`);
});
import type { File, FileTree } from '../types';
import sortFileTree from './sortFileTree';
const stripLeadingSlash = (str: string) => {
if (str[0] === '.' && str[1] === '/') {
return str.slice(2);
}
if (str[0] === '/') {
return str.slice(1);
}
return str;
};
export default function composeFileTree(files: Array<File>) {
const result: FileTree = [];
type Level = {
result: FileTree;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} & Record<string, any>;
const level: Level = { result };
files.forEach((file) => {
const path = stripLeadingSlash(file.file_path);
const segments = path.split('/');
segments.reduce((acc, segment, currentIndex, array) => {
if (!acc[segment]) {
acc[segment] = { result: [] };
acc.result.push({
name: segment,
...(currentIndex === array.length - 1 ? file : { children: acc[segment].result }),
});
}
acc.result.sort(sortFileTree);
return acc[segment];
}, level);
});
return result.sort(sortFileTree);
}
import type { FileTree } from '../types';
import type ArrayElement from 'types/utils/ArrayElement';
export default function sortFileTree(a: ArrayElement<FileTree>, b: ArrayElement<FileTree>) {
if ('children' in a && !('children' in b)) {
return -1;
}
if ('children' in b && !('children' in a)) {
return 1;
}
return a.name.localeCompare(b.name);
}
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export const light: monaco.editor.IStandaloneThemeData = {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#f5f5f6',
},
};
export const dark: monaco.editor.IStandaloneThemeData = {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#1a1b1b',
},
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment