I cloned a simple VScode using Tauri and ReactJS
hudy9x
Posted on November 4, 2022
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:
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
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
And belows are main packages that we're gonna use in this tutorial
- codemirror - a code editor component for the web
- remixicon - simply delightful icon system, but only use several ones 🤣
- tailwindcss - a utility-first CSS framework
- nanoid - unique string ID generator
- cm6-theme-material-dark - the basic dark theme
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
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
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:
- helpers/filesys.ts - contains functions that calls tauri commands for reading folder, getting file content, ... from
main.rs
- src-tauri/src/main.rs - defines tauri commands, and call functions from
fc.rs
- src-tauri/src/fc.rs - contains main functions to reading folder, getting file content, ...
- stores/file.ts - stores file metadata
Ok, that's enough, let's get right into it
Coding time
Start development mode
$ yarn tauri dev
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
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
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
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>
}
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
}
Create a file called src/context/SourceContext.tsx
. It contains 2 main properties are:
selected
- stores the file id the user click onopened
- 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 state and function more easier
export const useSource = () => {
const { selected, setSelect, opened, addOpenedFile, delOpenedFile } = useContext(SourceContext)
return { selected, setSelect, opened, addOpenedFile, delOpenedFile }
}
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>
}
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 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>
}
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
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]);
})
})
}
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]
}
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<()>{ /**... */ }
// ...
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
- "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");
}
Cool ! the hardest part is done. Let's see whether it works or not 😂
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:
- When user click on folder, reuse
readDirectory
function and show all files/folders - Creating new file using
writeFile
function - 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>
}
Here is the result ! 😍
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>
}
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 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;
}
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>
}
Finally, let's see our final masterpiece 🤣
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
Posted on November 4, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.