Hi friends, it's Hudy!
Recently, I wrote a tutorial about how to build a notebook - desktop application - using Tauri. But, maybe that doesn’t really demonstrate the power of Tauri. As such, I decided to build another app - a simple code editor (a very basic version like VScode)
This app has some basic functions such as:
Final result:
Download link (only for windows)
If you're using macOS, please clone the repo and run the build command as follows:
// make sure that you installed Tauri already
$ yarn tauri build
Here is the youtube video tutorial version:
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
Below are the main packages that we're gonna use in this tutorial
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
Each part is equivalent to one specific reactjs component. For instance: Tab will be <Tab/>
component, Titlebar will be <Titlebar/>
component and so on
Next, the image illustrates how our code runs after the user loads a project folder or clicks on a file/folder.
Initially, users load a project folder on (1) Sidebar. All files/folders will be saved to (3) Stores - only metadata saved, not file content.
Next, every time a user clicks on a file, the 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 the file's name, and (5) display the selected file's content. End
In order to read the folder's content (Ex: files, folders), and get file content, then the following files are core:
main.rs
fc.rs
Ok, that's enough, let's get right into it
Start development mode
$ yarn tauri dev
The 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
Install some 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
In this tutorial, I'm gonna use tailwindcss for styling
$ yarn add tailwindcss postcss autoprefixer
After installing, head to this official guide for completing the installation
To save time, I created a style file here.
OK, The first thing we're gonna build is a Titlebar. It is the easiest part of the tutorial. This is because it just has 3 main features and we can use appWindow
inside the @tauri-apps/api/window
package to create these features.
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>
}
As I mentioned above, in order for components to communicate with each other, we need a state manager - <SourceContext/>
. Just define the IFile
interface first
// src/types/file.ts
export interface IFile {
id: string;
name: string;
kind: 'file' | 'directory';
path: string; // d://path/to/file
}
Create a file called src/context/SourceContext.tsx
. It contains 2 main properties are:
selected
- stores the file id the user clicks on
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) => { }
});
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>
}
In addition, I also create a hook called useSource
that helps using these above states and function easier
export const useSource = () => {
const { selected, setSelect, opened, addOpenedFile, delOpenedFile } = useContext(SourceContext)
return { selected, setSelect, opened, addOpenedFile, delOpenedFile }
}
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>
}
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} />
}
Alright! Now we just render 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 files and provide an action for each file.
When a user clicks 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>
}
OK, let's create src/helpers/filesys.ts
- this is our bridge from frontend to the 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 a folder, delete a file, or delete a folder). So we have to do this in rust
And the only way to communicate with rust code is with Tauri commands. The below diagram illustrates how Tauri commands work.
Tauri supports a function called invoke
. Let’s 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]);
})
})
}
Our store is very simple, it's an object as follows:
// 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]
}
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. Below are shortened versions, I won't explain it in detail, because I'm very new to rust and I’m still trying to workout the kinks.
If anyone resolves all warnings in this file, please let me know, and 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<()>{ /**... */ }
// ...
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
invoke
must be an object with the name in camelCaseSo, here are 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");
}
Cool ! the hardest part is done. Let's see whether it works or not 😂
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:
readDirectory
function and show all files/folderswriteFile
function<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>
}
Here is the result ! 😍
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 parts: a Tab and 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>
}
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>
}
For ease of scrolling, I wrote a 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;
}
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>
}
Finally, let's see our final masterpiece 🤣
Phew, that's a long tutorial, right? However, I hope it is useful for you guys. I still leave some functions to you to complete such as: create folder and delete file. Feel free to build your own code editor.
Lastly, thank you for taking the time to read the tutorial. If you have any questions, concerns, or suggestions please let me know in the comment section. Especially about rust
, like I said, I'm new to this language