I cloned a simple VScode using Tauri and ReactJS

hudy9x

hudy9x

Posted on November 4, 2022

I cloned a simple VScode using Tauri and ReactJS

Hi friends, it's hudy again

Recently, i wrote a tutorial about how to build a notebook - desktop application - using Tauri. But, maybe it's not showing up the power of Tauri. Due to that i decided to build another app - a simple code editor (a very basic version like VScode)

This app has some basic functions such as:

  • reading files and folders that contains in a project folder
  • creating new file
  • editing file content
  • display opened files in tab
  • showing folder structure
  • file icons

Prerequisite

  • Tauri
  • Reactjs
  • Understanding how React Context works

Final result:

final-result

Source code: https://github.com/hudy9x/huditor

Download link (only for window): https://github.com/hudy9x/huditor/releases/download/v0.0.0/huditor_0.0.0_x64_en-US.msi

If you're using MacOS, please clone the repo and run build command as follow

// make sure that you installed Tauri already
$ yarn tauri build
Enter fullscreen mode Exit fullscreen mode

Here is the youtube video tutorial version:

Code structure

src/
├─ assets/       // file icons
├─ components/   // react components
├─ context/      // data management
├─ helpers/      // file system api & hook
├─ types/        // file object store
├─ stores/       // typesrcipt types
src-tauri/       // tauri core
Enter fullscreen mode Exit fullscreen mode

And belows are main packages that we're gonna use in this tutorial

Code flow explanation

Before jumping into the code, you should take a look at the overview of how the code editor works. See the image below. You'll see that our code editor has 4 main parts: Titlebar, Sidebar, Tab and Editor

Main code parts

Each parts are equivalent to one specific reactjs component. For instance: Tab will be <Tab/> component, Titlebar will be <Titlebar/> component and so on

Next image illustrates how our code runs after user load a project folder or click on a file/folders

Code flow

Initially, user load project folder on (1) Sidebar. All files/folders will be saved to (3) Stores - only metadata saved, not file content.

Next, every time an user click on a file then file's id will be passed to (2) SourceContext - our state management.

When (2) get a new selected_file then automatically (4) will add a new tab with file's name, and (5) display selected file's content. End

The core of our editor - the most important files

In order to read folder's content (Ex: files, folders) and get file content then the following files are core:

  1. helpers/filesys.ts - contains functions that calls tauri commands for reading folder, getting file content, ... from main.rs
  2. src-tauri/src/main.rs - defines tauri commands, and call functions from fc.rs
  3. src-tauri/src/fc.rs - contains main functions to reading folder, getting file content, ...
  4. stores/file.ts - stores file metadata

Ok, that's enough, let's get right into it

Coding time

Start development mode

$ yarn tauri dev
Enter fullscreen mode Exit fullscreen mode

1. Scaffolding code base

First step, initialize the project code base. Run the following commands and remember to select a package manager - mine is yarn

$ npm create tauri-app huditor
Enter fullscreen mode Exit fullscreen mode

Install somes packages

$ yarn add remixicon nanoid codemirror cm6-theme-material-dark
$ yarn add @codemirror/lang-css @codemirror/lang-html @codemirror/lang-javascript @codemirror/lang-json @codemirror/lang-markdown @codemirror/lang-rust
Enter fullscreen mode Exit fullscreen mode

In this tutorial, i'm gonna use tailwindcss for styling

$ yarn add tailwindcss postcss autoprefixer
Enter fullscreen mode Exit fullscreen mode

After installing, head to this official guide for completing the installation

For saving time, i created a style file here: https://github.com/hudy9x/huditor/blob/main/src/style.css

2. Customize the Titlebar

OK, The first thing we're gonna build is Titlebar. It is the easiest part in the tutorial. Because it just has 3 main features and we can use appWindow inside @tauri-apps/api/window package to do these

import { useState } from "react";
import { appWindow } from "@tauri-apps/api/window";

export default function Titlebar() {
  const [isScaleup, setScaleup] = useState(false);
  // .minimize() - to minimize the window
  const onMinimize = () => appWindow.minimize();
  const onScaleup = () => {
    // .toggleMaximize() - to swap the window between maximize and minimum
    appWindow.toggleMaximize();
    setScaleup(true);
  }

  const onScaledown = () => {
    appWindow.toggleMaximize();
    setScaleup(false);
  }

// .close() - to close the window
  const onClose = () => appWindow.close();

  return <div id="titlebar" data-tauri-drag-region>
    <div className="flex items-center gap-1 5 pl-2">
      <img src="/tauri.svg" style={{ width: 10 }} alt="" />
      <span className="text-xs uppercase">huditor</span>
    </div>

    <div className="titlebar-actions">
      <i className="titlebar-icon ri-subtract-line" onClick={onMinimize}></i>

      {isScaleup ? <i className="titlebar-icon ri-file-copy-line" onClick={onScaledown}></i> : <i onClick={onScaleup} className="titlebar-icon ri-stop-line"></i>}

      <i id="ttb-close" className="titlebar-icon ri-close-fill" onClick={onClose}></i>
    </div>
  </div>
}

Enter fullscreen mode Exit fullscreen mode

3. Create the SourceContext

As i mentioned above, in order to components communicate with each others we need a state manager - <SourceContext/>. Just define IFile interface fisrt

// src/types/file.ts

export interface IFile {
  id: string;
  name: string;
  kind: 'file' | 'directory';
  path: string;  // d://path/to/file
}
Enter fullscreen mode Exit fullscreen mode

Create a file called src/context/SourceContext.tsx. It contains 2 main properties are:

  1. selected - stores the file id the user click on

  2. opened - contains a list of file id. Ex: ['387skwje', 'ids234oijs', '92kjsdoi4', ...]

// src/context/SourceContext.tsx

import { createContext, useContext, useState, useCallback } from "react"

interface ISourceContext {
  selected: string;
  setSelect: (id: string) => void;
  opened: string[];
  addOpenedFile: (id: string) => void;
  delOpenedFile: (id: string) => void;
}

const SourceContext = createContext<ISourceContext>({
  selected: '',
  setSelect: (id) => { },
  opened: [],
  addOpenedFile: (id) => { },
  delOpenedFile: (id) => { }
});
Enter fullscreen mode Exit fullscreen mode

Create <SourceProvider/> to passing our states to child components

// src/context/SourceContext.tsx
// ....
export const SourceProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => {
  const [selected, setSelected] = useState('');
  const [opened, updateOpenedFiles] = useState<string[]>([]);

  const setSelect = (id: string) => {
    setSelected(id)
  }

  const addOpenedFile = useCallback((id: string) => {
    if (opened.includes(id)) return;
    updateOpenedFiles(prevOpen => ([...prevOpen, id]))
  }, [opened])

  const delOpenedFile = useCallback((id: string) => {
    updateOpenedFiles(prevOpen => prevOpen.filter(opened => opened !== id))
  }, [opened])

  return <SourceContext.Provider value={{
    selected,
    setSelect,
    opened,
    addOpenedFile,
    delOpenedFile
  }}>
    {children}
  </SourceContext.Provider>
}
Enter fullscreen mode Exit fullscreen mode

In addition, i also create a hook called useSource that helps using these above state and function more easier

export const useSource = () => {
  const { selected, setSelect, opened, addOpenedFile, delOpenedFile } = useContext(SourceContext)

  return { selected, setSelect, opened, addOpenedFile, delOpenedFile }
}
Enter fullscreen mode Exit fullscreen mode

4. Create the Sidebar

Now, time to create the sidebar. I break the sidebar into 2 parts:

  • one for a header - that contains a button and project name
  • the other for <NavFiles> - a list of files/folders that loaded by clicking the above button
// src/components/Sidebar.tsx
import { useState } from "react";
import { IFile } from "../types";
import { open } from "@tauri-apps/api/dialog";
// we're gonna create <NavFiles> and `filesys.ts` belows
import NavFiles from "./NavFiles";
import { readDirectory } from "../helpers/filesys";

export default function Sidebar() {
  const [projectName, setProjectName] = useState("");
  const [files, setFiles] = useState<IFile[]>([]);

  const loadFile = async () => {
    const selected = await open({
      directory: true
    })

    if (!selected) return;

    setProjectName(selected as string)
    // .readDirectory accepts a folder path and return 
    // a list of files / folders that insides it
    readDirectory(selected + '/').then(files => {
      console.log(files)
      setFiles(files)
    })
  }

  return <aside id="sidebar" className="w-60 shrink-0 h-full bg-darken">
    <div className="sidebar-header flex items-center justify-between p-4 py-2.5">
      <button className="project-explorer" onClick={loadFile}>File explorer</button>
      <span className="project-name whitespace-nowrap text-gray-400 text-xs">{projectName}</span>  
    </div>
    <div className="code-structure">
      <NavFiles visible={true} files={files}/>
    </div>
  </aside>
}
Enter fullscreen mode Exit fullscreen mode

Prepare a <FileIcon /> to show file thumbnail. Checkout the github project to get all assets

// src/components/FileIcon.tsx
// assets link: https://github.com/hudy9x/huditor/tree/main/src/assets
import html from '../assets/html.png';
import css from '../assets/css.png';
import react from '../assets/react.png';
import typescript from '../assets/typescript.png';
import binary from '../assets/binary.png';
import content from '../assets/content.png';
import git from '../assets/git.png';
import image from '../assets/image.png';
import nodejs from '../assets/nodejs.png';
import rust from '../assets/rust.png';
import js from '../assets/js.png';

interface Icons {
  [key: string]: string
}

const icons: Icons = {
  tsx: react,
  css: css,
  svg: image,
  png: image,
  icns: image,
  ico: image,
  gif: image,
  jpeg: image,
  jpg: image,
  tiff: image,
  bmp: image,
  ts: typescript,
  js,
  json: nodejs,
  md: content,
  lock: content,
  gitignore: git,
  html: html,
  rs: rust,
};

interface IFileIconProps {
  name: string;
  size?: 'sm' | 'base'
}

export default function FileIcon({ name, size = 'base' }: IFileIconProps) {
  const lastDotIndex = name.lastIndexOf('.')
  const ext = lastDotIndex !== -1 ? name.slice(lastDotIndex + 1).toLowerCase() : 'NONE'
  const cls = size === 'base' ? 'w-4' : 'w-3';

  if (icons[ext]) {
    return <img className={cls} src={icons[ext]} alt={name} />
  }

  return <img className={cls} src={binary} alt={name} />
}
Enter fullscreen mode Exit fullscreen mode

Alright, now we just renders all files that passed from <Sidebar/>. Create a file called src/components/NavFiles.tsx. Inside the file, we should split the code into 2 parts: render file and render folder.

I'll explain the folder view (<NavFolderItem/>) later. About render file, we only need to list all file and provide for each file an action.

When user click on file, not folder, call onShow add pass file id to opened state in context using addOpenedfile function

// src/components/NavFiles.tsx

import { MouseEvent } from "react"
import { useSource } from "../context/SourceContext"
import { IFile } from "../types"
import FileIcon from "./FileIcon"
import NavFolderItem from "./NavFolderItem" // this will be defined later

interface Props {
  files: IFile[]
  visible: boolean
}

export default function NavFiles({files, visible}: Props) {
  const {setSelect, selected, addOpenedFile} = useSource()

  const onShow = async (ev: React.MouseEvent<HTMLDivElement, MouseEvent>, file: IFile) => {

    ev.stopPropagation();

    if (file.kind === 'file') {
      setSelect(file.id)
      addOpenedFile(file.id)
    }

  }

  return <div className={`source-codes ${visible ? '' : 'hidden'}`}>
    {files.map(file => {
      const isSelected = file.id === selected;

      if (file.kind === 'directory') {
        return <NavFolderItem active={isSelected} key={file.id} file={file} />
      }

      return <div onClick={(ev) => onShow(ev, file)}
        key={file.id}
        className={`soure-item ${isSelected ? 'source-item-active' : ''} flex items-center gap-2 px-2 py-0.5 text-gray-500 hover:text-gray-400 cursor-pointer`}
      >
        <FileIcon name={file.name} />
        <span>{file.name}</span>
      </div>
    })}
  </div>
}
Enter fullscreen mode Exit fullscreen mode

5. Create helpers/filesys.ts

OK, let's create src/helpers/filesys.ts - this is our bridge from frontend to Tauri core. Let me clarify this, as you may know, javascript api does not support reading/writing files/folders for now (tried File System Access API, but can't create folder, delete file, delete folder). So we have to do this in rust

And the only way to communicate with rust code is that using Tauri commands. The below diagram illustrates how Tauri command works

rust-command

Tauri supports a function called invoke. Let import that function from @tauri-apps/api/tauri and call to get_file_content, write_file, and open_folder commands

// src/helpers/filesys.ts
import { invoke } from "@tauri-apps/api/tauri"
import { nanoid } from "nanoid"
import { saveFileObject } from "../stores/file" // we'll defines this file below
import { IFile } from "../types"

export const readFile = (filePath: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    // get the file content
    invoke("get_file_content", {filePath}).then((message: unknown) => {
      resolve(message as string);
    }).catch(error => reject(error))
  })
}

export const writeFile = (filePath: string, content: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    // write content in file or create a new one
    invoke("write_file", { filePath, content }).then((message: unknown) => {
      if (message === 'OK') {
        resolve(message as string)
      } else {
        reject('ERROR')
      }
    })
  })
}

export const readDirectory = (folderPath: string): Promise<IFile[]> => {
  return new Promise((resolve, reject) => {
    // get all files/folders inside `folderPath`
    invoke("open_folder", { folderPath }).then((message: unknown) => {
      const mess = message as string;
      const files = JSON.parse(mess.replaceAll('\\', '/').replaceAll('//', '/'));
      const entries: IFile[] = [];
      const folders: IFile[] = [];

      if (!files || !files.length) {
        resolve(entries);
        return;
      }

      for (let i = 0; i < files.length; i++) {
        const file = files[i];
        const id = nanoid();
        const entry: IFile = {
          id,
          kind: file.kind,
          name: file.name,
          path: file.path
        }

        if (file.kind === 'file') {
          entries.push(entry)
        } else {
          folders.push(entry)
        }

        // save file metadata to store, i mentioned this above
        // scroll up if any concerns
        saveFileObject(id, entry)

      }

      resolve([...folders, ...entries]);

    })
  })
}

Enter fullscreen mode Exit fullscreen mode

6. Create stores/files.ts

Our store is very simple, it's an object as follow

// src/stores/files.ts
import { IFile } from "../types"

// Ex: {
//   "34sdjwyd3": {
//     "id": "34sdjwyd3",
//     "name": "App.tsx",
//     "kind": "file",
//     "path": "d://path/to/App.tsx",
//   },
//   "872dwehud": {
//     "id": "872dwehud",
//     "name": "components",
//     "kind": "directory",
//     "path": "d://path/to/components",
//   }
// }
interface IEntries {
  [key: string]: IFile
}

const entries: IEntries = {}

export const saveFileObject = (id: string, file: IFile): void => {
  entries[id] = file
}

export const getFileObject = (id: string): IFile => {
  return entries[id]
}
Enter fullscreen mode Exit fullscreen mode

7. Define Tauri commands

The most important file is here, i've created that before, so you guys can find the full source here https://github.com/hudy9x/huditor/blob/main/src-tauri/src/fc.rs. Belows are shorten version, i won't explain it in details, cuz i'm a very new in rust

If anyone resolves all warnings in this file, please let me know, i'll update it. Thanks !

// src-tauri/src/fc.rs
// ...

pub fn read_directory(dir_path: &str) -> String { /**... */ }

pub fn read_file(path: &str) -> String { /**... */ }

pub fn write_file(path: &str, content: &str) -> String { /**... */ }

// These are not used in this tutorial
// i leave it to you guys 😁
pub fn create_directory(path: &str) -> Result<()>{/**... */ }

pub fn remove_file(path: &str) -> Result<()> {/**... */ }

pub fn remove_folder(path: &str) -> Result<()>{ /**... */ }

// ...

Enter fullscreen mode Exit fullscreen mode

The only task you guys need to do is define commands. One important thing you have to keep in mind when defining commands and calling them is that:

invoke("write_file", {filePath, content}) // frontend
write_file(file_path: &str, content: &str) // tauri command
Enter fullscreen mode Exit fullscreen mode
  • "write_file" : name must be the same
  • params in invoke must be object with name in camelCase
  • params in tauri command must be snake_case

So, here is our commands

// src-tauri/src/main.rs

#![cfg_attr(
    all(not(debug_assertions), target_os = "windows"),
    windows_subsystem = "windows"
)]

mod fc;

// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

#[tauri::command]
fn open_folder(folder_path: &str) -> String {
    let files = fc::read_directory(folder_path);
    files
}

#[tauri::command]
fn get_file_content(file_path: &str) -> String {
    let content = fc::read_file(file_path);
    content
}

#[tauri::command]
fn write_file(file_path: &str, content: &str) -> String {
    fc::write_file(file_path, content);
    String::from("OK")
}

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![greet, open_folder, get_file_content, write_file])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Enter fullscreen mode Exit fullscreen mode

Cool ! the hardest part is done. Let's see whether it works or not 😂

sidebar-demo

8. Displays files/folders inside subfolder

The last one to complete our Sidebar is displaying files/folders inside subfolder. Create a new file src/components/NavFolderItem.tsx. It does some tasks:

  1. When user click on folder, reuse readDirectory function and show all files/folders
  2. Creating new file using writeFile function
  3. Reuse <NavFiles /> to display files/folders
// src/components/NavFolderItem.tsx

import { nanoid } from "nanoid";
import { useState } from "react";
import { readDirectory, writeFile } from "../helpers/filesys";
import { saveFileObject } from "../stores/file";
import { IFile } from "../types";
import NavFiles from "./NavFiles";

interface Props {
  file: IFile;
  active: boolean;
}

export default function NavFolderItem({ file, active }: Props) {
  const [files, setFiles] = useState<IFile[]>([])
  const [unfold, setUnfold] = useState(false)
  const [loaded, setLoaded] = useState(false)
  const [newFile, setNewFile] = useState(false)
  const [filename, setFilename] = useState('')

  const onShow = async (ev: React.MouseEvent<HTMLSpanElement, MouseEvent>) => {
    ev.stopPropagation()

    // if files/foldes are loaded, just show it
    if (loaded) {
      setUnfold(!unfold)
      return;
    }

    const entries = await readDirectory(file.path + '/')

    setLoaded(true)
    setFiles(entries)
    setUnfold(!unfold)
  }

  const onEnter = (key: string) => { 
    if (key === 'Escape') {
      setNewFile(false)
      setFilename('')
      return;
    }

    if (key !== 'Enter') return;

    const filePath = `${file.path}/${filename}`

    // Create new file when Enter key pressed
    writeFile(filePath, '').then(() => {
      const id = nanoid();
      const newFile: IFile = {
        id,
        name: filename,
        path: filePath,
        kind: 'file'
      }

      saveFileObject(id, newFile)
      setFiles(prevEntries => [newFile, ...prevEntries])
      setNewFile(false)
      setFilename('')
    })

  }

  return <div className="soure-item">
    <div className={`source-folder ${active ? 'bg-gray-200' : ''} flex items-center gap-2 px-2 py-0.5 text-gray-500 hover:text-gray-400 cursor-pointer`}>
      <i className="ri-folder-fill text-yellow-500"></i>
      <div className="source-header flex items-center justify-between w-full group">
        <span onClick={onShow}>{file.name}</span>
        <i onClick={() => setNewFile(true)} className="ri-add-line invisible group-hover:visible"></i>
      </div>
    </div>

      {newFile ? <div className="mx-4 flex items-center gap-0.5 p-2">
      <i className="ri-file-edit-line text-gray-300"></i>
      <input type="text" value={filename} 
        onChange={(ev) => setFilename(ev.target.value)}
        onKeyUp={(ev) => onEnter(ev.key)}
        className="inp"
        />
    </div> : null}

    <NavFiles visible={unfold} files={files} />
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Here is the result ! 😍

sidebar-complete

9. Displays file name on tab

Time to show selected files on the tab. It's so simple, just call useSource it contains anything we need. Now, create src/components/CodeArea.tsx, it has 2 part: a Tab and a content inside

Notice that codemirror does not read image files, so we have to create another component to show it

import { useRef } from "react"
import { convertFileSrc } from "@tauri-apps/api/tauri"

interface Props {
  path: string;
  active: boolean;
}

export default function PreviewImage({ path, active }: Props) {
  const imgRef = useRef<HTMLImageElement>(null)

  return <div className={`${active ? '' : 'hidden'} p-8`}>
    <img ref={imgRef} src={convertFileSrc(path)} alt="" />
  </div>
}

Enter fullscreen mode Exit fullscreen mode

Then import <PreviewImage/> into <CodeArea/>

// src/components/CodeArea.tsx

import { IFile } from "../types"
import { useSource } from "../context/SourceContext"
import { getFileObject } from "../stores/file"
import FileIcon from "./FileIcon"
import useHorizontalScroll from "../helpers/useHorizontalScroll" // will be define later
import PreviewImage from "./PreviewImage"
import CodeEditor from "./CodeEditor" // will be define later

export default function CodeArea() {
  const { opened, selected, setSelect, delOpenedFile } = useSource()
  const scrollRef = useHorizontalScroll()
  const onSelectItem = (id: string) => {
    setSelect(id)
  }

  const isImage = (name: string) => {
    return ['.png', '.gif', '.jpeg', '.jpg', '.bmp'].some(ext => name.lastIndexOf(ext) !== -1) 
  }

  const close = (ev: React.MouseEvent<HTMLElement, MouseEvent>, id: string) => {
    ev.stopPropagation()
    delOpenedFile(id)
  }

  return <div id="code-area" className="w-full h-full">

    {/** This area is for tab bar */}

    <div ref={scrollRef} className="code-tab-items flex items-center border-b border-stone-800 divide-x divide-stone-800 overflow-x-auto">
      {opened.map(item => {
        const file = getFileObject(item) as IFile;
        const active = selected === item ? 'bg-darken text-gray-400' : ''

        return <div onClick={() => onSelectItem(file.id)} className={`tab-item shrink-0 px-3 py-1.5 text-gray-500 cursor-pointer hover:text-gray-400 flex items-center gap-2 ${active}`} key={item}>
          <FileIcon name={file.name} size="sm" />
          <span>{file.name}</span>
          <i onClick={(ev) => close(ev, item)} className="ri-close-line hover:text-red-400"></i>
        </div>
      })}
    </div>

    {/** This area is for code content */}

    <div className="code-contents">
      {opened.map(item => {
        const file = getFileObject(item) as IFile;
        if (isImage(file.name)) {
          return <PreviewImage path={file.path} active={item === selected} />
        }

        return <CodeEditor key={item} id={item} active={item===selected} />

        })}
    </div>
  </div>
}

Enter fullscreen mode Exit fullscreen mode

For ease of scrolling, I wrote an hook to scroll the tab bar horizontally

// src/helpers/useHorizontalScroll.ts
import { useRef, useEffect } from "react"

export default function useHorizontalScroll() {
  const ref = useRef<HTMLDivElement | null>(null)

  useEffect(() => {
    const elem = ref.current
    const onWheel = (ev: WheelEvent) => {
      if (!elem || ev.deltaY === 0) return;

      elem.scrollTo({
        left: elem.scrollLeft + ev.deltaY,
        behavior: 'smooth'
      })
    }

    elem && elem.addEventListener('wheel', onWheel)

    return () => {
      elem && elem.removeEventListener('wheel', onWheel)
    }
  }, [])

  return ref;

}
Enter fullscreen mode Exit fullscreen mode

tab-items

10. Show up the file content

Let's finish the work now

// src/components/CodeEditor.tsx

import { nanoid } from "nanoid";
import { useEffect, useMemo, useRef } from "react";
import { getFileObject } from "../stores/file";
import { readFile, writeFile } from "../helpers/filesys";

// these packages will be used for codemirror
import { EditorView, basicSetup } from "codemirror";
// hightlight js, markdown, html, css, json, ...
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { rust } from "@codemirror/lang-rust";
// codemirror theme in dark
import { materialDark } from "cm6-theme-material-dark";

interface Props {
  id: string;
  active: boolean;
}

export default function CodeEditor({ id, active }: Props) {
  const isRendered = useRef(0)
  const editorId = useMemo(() => nanoid(), [])
  const visible = active ? '' : 'hidden'
  const editorRef = useRef<EditorView | null>(null)

  // get file metadata by id from /stores/file.ts
  const updateEditorContent = async (id: string) => {
    const file = getFileObject(id);
    const content = await readFile(file.path)

    fillContentInEditor(content)

  }

  // fill content into codemirror
  const fillContentInEditor = (content: string) => {
    const elem = document.getElementById(editorId)

    if (elem && isRendered.current === 0) {
      isRendered.current = 1;
      editorRef.current = new EditorView({
        doc: content,
        extensions: [
          basicSetup,
          javascript(), markdown(), html(), css(), json(), rust(),
          materialDark
        ],
        parent: elem
      })
    }
  }

  // save the content when pressing Ctrl + S
  const onSave = async () => {
    if (!editorRef.current) return;

    // get codemirror's content
    // if any other way to get content, please let me know in the comment section
    const content = editorRef.current.state.doc.toString();
    const file = getFileObject(id)

    writeFile(file.path, content)
  }

  useEffect(() => {
    updateEditorContent(id)
  }, [id])

  return <main className={`w-full overflow-y-auto ${visible}`} style={{ height: 'calc(100vh - 40px)' }}>
    <div id={editorId} tabIndex={-1} onKeyUp={(ev) => {
      if (ev.ctrlKey && ev.key === 's') {
        ev.preventDefault()
        ev.stopPropagation()
        onSave()
      }
    }}></div>

  </main>

}

Enter fullscreen mode Exit fullscreen mode

Finally, let's see our final masterpiece 🤣

finish

Conclusion

Phew, that's a long tutorial, right?. However, i hope it useful for you guys. I still leaves some functions to you to complete such as: create folder, delete file. Feel free to build your own code editor

Lastly, thank for taking your time to read the tutorial. If any question, concern, suggestion please let me know in the commen section. Especially about rust, cuz i'm a new to this language

💖 💪 🙅 🚩
hudy9x
hudy9x

Posted on November 4, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related