JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog

Build a Code Editor Like VS Code, Part 7: A Real File Tree and Four More Features

AdminJune 26, 202613 min read
Build a Code Editor Like VS Code, Part 7: A Real File Tree and Four More Features

On this page

  • Upgrading the file tree to react-arborist
  • A tree-shaped data source
  • Lifting the folder into App
  • File-type icons
  • Feature 1: A command palette
  • Feature 2: Quick Open
  • Feature 3: A status bar
  • Feature 4: Language services and autocomplete
  • Telling the TypeScript service how to read your files
  • Future work: the rest of VS Code

On this page

  • Upgrading the file tree to react-arborist
  • A tree-shaped data source
  • Lifting the folder into App
  • File-type icons
  • Feature 1: A command palette
  • Feature 2: Quick Open
  • Feature 3: A status bar
  • Feature 4: Language services and autocomplete
  • Telling the TypeScript service how to read your files
  • Future work: the rest of VS Code
PreviousBuild a Code Editor Like VS Code, Part 6: Syntax Highlighting and PolishNextDx Music League Part 1 : A Fake App That Feels Real

Build a Code Editor Like VS Code, Part 7: A Real File Tree and Four More Features

Part 6 was the end of the core build. This part is optional, and it changes register: instead of building the next necessary piece, we level up the pieces we have to look more like the real thing. We swap our hand-rolled file tree for a production-grade one, give it real file-type icons, add four signature VS Code features, and finish with a map of everything still left to build.

Upgrading the file tree to react-arborist

Our hand-rolled tree from Part 3 taught the concept, but it has real limits. It renders every visible row even in a huge folder, it has no keyboard navigation, and it can't rename or move files. react-arborist solves all of that. It virtualizes rows so a folder with ten thousand files stays smooth, it handles arrow-key navigation and type-to-select for free, and it gives us inline rename and drag-to-move with a callback each.

bash
npm install react-arborist

A tree-shaped data source

Arborist wants the data as a tree of nodes, each with a stable id and optional children. That's a different shape from our Part 3 readDir, which returned one flat level at a time. So we add a main-process handler that walks the directory recursively, skipping the heavy folders you never want to render.

First the shared type, in src/shared/types.ts:

ts
export interface TreeNode {
  id: string // absolute path, used as the stable id
  name: string
  children?: TreeNode[] // present on directories, absent on files
}

Then the handlers in the main process, alongside the ones from Part 3. We add a recursive read, a rename, and a move:

ts
import { readdir, rename } from 'fs/promises'
import { join, dirname, basename } from 'path'
import type { TreeNode } from '../shared/types'
 
const IGNORE = new Set(['node_modules', '.git', '.DS_Store'])
 
async function readTree(dir: string): Promise<TreeNode[]> {
  const entries = await readdir(dir, { withFileTypes: true })
  const nodes = await Promise.all(
    entries
      .filter((e) => !IGNORE.has(e.name))
      .map(async (e) => {
        const full = join(dir, e.name)
        return e.isDirectory()
          ? { id: full, name: e.name, children: await readTree(full) }
          : { id: full, name: e.name }
      })
  )
  return nodes.sort((a, b) => {
    const aDir = Boolean(a.children)
    const bDir = Boolean(b.children)
    if (aDir !== bDir) return aDir ? -1 : 1
    return a.name.localeCompare(b.name)
  })
}
 
function registerTreeHandlers(): void {
  ipcMain.handle('fs:readTree', (_e, dir: string) => readTree(dir))
 
  ipcMain.handle('fs:rename', async (_e, path: string, name: string) => {
    const target = join(dirname(path), name)
    await rename(path, target)
    return target
  })
 
  ipcMain.handle('fs:move', async (_e, src: string, destDir: string) => {
    const target = join(destDir, basename(src))
    await rename(src, target)
    return target
  })
}

Call registerTreeHandlers() in the whenReady block. The trade we're making: this walks the whole tree up front instead of lazily per folder. Arborist virtualizes the rendering, so a big tree is cheap to display, but the initial walk does cost more than Part 3's lazy load. Skipping node_modules and .git keeps it sane for real projects. Lazy-loading into arborist is possible by patching its data on expand, and it's a good exercise once the rest works.

Expose the three handlers through preload, next to the Part 3 functions:

ts
readTree: (dir: string): Promise<TreeNode[]> => ipcRenderer.invoke('fs:readTree', dir),
rename: (path: string, name: string): Promise<string> =>
  ipcRenderer.invoke('fs:rename', path, name),
move: (src: string, destDir: string): Promise<string> =>
  ipcRenderer.invoke('fs:move', src, destDir),

And add their signatures to the window.api type in src/preload/index.d.ts.

Lifting the folder into App

For Quick Open later in this part, App needs to know the open folder and its files, not just the explorer. So we move the folder state up: App owns rootDir and the loaded tree, opens the folder, and passes the data and handlers down to a now presentational FileExplorer. This is the same lift we did for the open file in Part 3, applied to the workspace.

src/renderer/src/explorer/FileExplorer.tsx:

tsx
import { useEffect, useRef, useState } from 'react'
import { Tree, type NodeRendererProps } from 'react-arborist'
import type { TreeNode } from '../../../shared/types'
 
interface Props {
  tree: TreeNode[] | null
  onOpenFolder: () => void
  onOpenFile: (path: string) => void
  onRename: (path: string, name: string) => void
  onMove: (src: string, destDir: string) => void
}
 
export default function FileExplorer({
  tree,
  onOpenFolder,
  onOpenFile,
  onRename,
  onMove
}: Props) {
  const ref = useRef<HTMLDivElement>(null)
  const [size, setSize] = useState({ width: 0, height: 0 })
 
  // Arborist needs an explicit pixel size, so we measure the container. The
  // container only renders once we have a tree, so we re-run when `tree`
  // appears: on the first mount it doesn't exist yet, and an effect with an
  // empty dependency list would never attach the observer to it.
  useEffect(() => {
    const el = ref.current
    if (!el) return
    const ro = new ResizeObserver(() =>
      setSize({ width: el.clientWidth, height: el.clientHeight })
    )
    ro.observe(el)
    return () => ro.disconnect()
  }, [tree])
 
  if (!tree) {
    return (
      <div className="explorer-empty">
        <button className="open-btn" onClick={onOpenFolder}>
          Open Folder
        </button>
      </div>
    )
  }
 
  return (
    <div ref={ref} className="tree-container">
      <Tree
        data={tree}
        idAccessor="id"
        childrenAccessor="children"
        openByDefault={false}
        width={size.width}
        height={size.height}
        indent={12}
        rowHeight={24}
        onActivate={(node) => node.isLeaf && onOpenFile(node.id)}
        onRename={({ id, name }) => onRename(id, name)}
        onMove={({ dragIds, parentId }) =>
          dragIds.forEach((id) => onMove(id, parentId ?? ''))
        }
      >
        {Node}
      </Tree>
    </div>
  )
}
 
function Node({ node, style, dragHandle }: NodeRendererProps<TreeNode>) {
  return (
    <div
      className="tree-row"
      style={style}
      ref={dragHandle}
      onClick={() => !node.isLeaf && node.toggle()}
    >
      <span className="tree-icon">
        {node.isLeaf ? '·' : node.isOpen ? '▾' : '▸'}
      </span>
      {node.isEditing ? (
        <input
          className="tree-input"
          autoFocus
          defaultValue={node.data.name}
          onBlur={() => node.reset()}
          onKeyDown={(e) => {
            if (e.key === 'Escape') node.reset()
            if (e.key === 'Enter') node.submit(e.currentTarget.value)
          }}
        />
      ) : (
        <span className="tree-label">{node.data.name}</span>
      )}
    </div>
  )
}

A few arborist specifics worth naming. onActivate fires when a row is opened with a click or Enter, which is where we open files. The Node render function gets a node object with the state and methods we need: isLeaf, isOpen, isEditing, plus toggle(), submit(value), and reset(). Inline rename is built in: pressing Enter on a selected node sets node.isEditing, and our input calls node.submit to commit, which fires onRename. The dragHandle ref is what makes a row draggable, and dropping fires onMove. To start a rename from the keyboard, arborist uses Enter by default; you can also call node.edit() from a context menu later.

In App, own the workspace and wire the persistence handlers to refresh the tree after a change:

tsx
const [rootDir, setRootDir] = useState<string | null>(null)
const [tree, setTree] = useState<TreeNode[] | null>(null)
 
async function openFolder(): Promise<void> {
  const dir = await window.api.openFolder()
  if (!dir) return
  setRootDir(dir)
  setTree(await window.api.readTree(dir))
}
 
async function refreshTree(): Promise<void> {
  if (rootDir) setTree(await window.api.readTree(rootDir))
}
 
async function renamePath(path: string, name: string): Promise<void> {
  await window.api.rename(path, name)
  await refreshTree()
}
 
async function movePath(src: string, destDir: string): Promise<void> {
  await window.api.move(src, destDir || rootDir!)
  await refreshTree()
}

Then render <FileExplorer tree={tree} onOpenFolder={openFolder} onOpenFile={openFile} onRename={renamePath} onMove={movePath} /> in the sidebar pane. Re-reading the tree after each change is the simplest way to stay correct; patching the in-memory tree is a later optimization.

A little CSS so the tree fills its pane and the rename input looks right:

css
.tree-container { height: 100%; }
 
.tree-input {
  flex: 1;
  background: #3c3c3c;
  border: 1px solid var(--focus-border, #0e639c);
  color: var(--text);
  font: inherit;
  padding: 0 4px;
  outline: none;
}

Open a folder and the tree now scrolls smoothly, navigates with the arrow keys, renames in place when you press Enter, and moves files when you drag them between folders. All of that was a library swap plus three small IPC handlers.

File-type icons

The tree works, but the · and ▾ glyphs give away that it's hand-built. Real editors show a colored icon per file type, plus a folder that opens and closes, which is what makes a tree scannable at a glance. We can get that look without shipping any image files by using react-icons. It bundles thousands of icons as React components and tree-shakes down to only the ones you import, so the cost is a few kilobytes.

bash
npm install react-icons

Two of its icon sets cover everything we need. The vsc set is VS Code's own Codicons, which gives us the folder and chevron glyphs. The si set is Simple Icons, the brand marks for languages and tools, which is where the colored TypeScript, React, and CSS logos come from. We keep the mapping in its own module so adding a file type later is a single line.

src/renderer/src/explorer/fileIcons.tsx:

tsx
import type { ComponentType, CSSProperties } from 'react'
import { VscFile, VscFolder, VscFolderOpened, VscJson } from 'react-icons/vsc'
import {
  SiTypescript,
  SiJavascript,
  SiReact,
  SiCss,
  SiSass,
  SiLess,
  SiHtml5,
  SiMarkdown,
  SiYaml,
  SiPython,
  SiRust,
  SiGo,
  SiRuby,
  SiPhp,
  SiGnubash,
  SiC,
  SiCplusplus,
  SiDocker,
  SiGit
} from 'react-icons/si'
 
// react-icons components accept (and ignore) any extra props, so this narrow
// shape is enough to render them with a className and an inline color.
type IconComponent = ComponentType<{ className?: string; style?: CSSProperties }>
 
export interface IconSpec {
  Icon: IconComponent
  color: string
}
 
// Extension to glyph plus brand color, the Seti/VS Code look. Add a row to extend.
const BY_EXT: Record<string, IconSpec> = {
  ts: { Icon: SiTypescript, color: '#3178c6' },
  tsx: { Icon: SiReact, color: '#61dafb' },
  js: { Icon: SiJavascript, color: '#f0db4f' },
  jsx: { Icon: SiReact, color: '#61dafb' },
  mjs: { Icon: SiJavascript, color: '#f0db4f' },
  cjs: { Icon: SiJavascript, color: '#f0db4f' },
  json: { Icon: VscJson, color: '#cbcb41' },
  css: { Icon: SiCss, color: '#1572b6' },
  scss: { Icon: SiSass, color: '#cc6699' },
  sass: { Icon: SiSass, color: '#cc6699' },
  less: { Icon: SiLess, color: '#2a4d80' },
  html: { Icon: SiHtml5, color: '#e34c26' },
  htm: { Icon: SiHtml5, color: '#e34c26' },
  md: { Icon: SiMarkdown, color: '#519aba' },
  markdown: { Icon: SiMarkdown, color: '#519aba' },
  yml: { Icon: SiYaml, color: '#cb171e' },
  yaml: { Icon: SiYaml, color: '#cb171e' },
  py: { Icon: SiPython, color: '#3572a5' },
  rs: { Icon: SiRust, color: '#dea584' },
  go: { Icon: SiGo, color: '#00add8' },
  rb: { Icon: SiRuby, color: '#cc342d' },
  php: { Icon: SiPhp, color: '#777bb4' },
  sh: { Icon: SiGnubash, color: '#4eaa25' },
  bash: { Icon: SiGnubash, color: '#4eaa25' },
  zsh: { Icon: SiGnubash, color: '#4eaa25' },
  c: { Icon: SiC, color: '#a8b9cc' },
  h: { Icon: SiC, color: '#a8b9cc' },
  cpp: { Icon: SiCplusplus, color: '#00599c' },
  cc: { Icon: SiCplusplus, color: '#00599c' },
  hpp: { Icon: SiCplusplus, color: '#00599c' }
}
 
// A few files are recognised by their full name, not their extension.
const BY_NAME: Record<string, IconSpec> = {
  dockerfile: { Icon: SiDocker, color: '#2496ed' },
  '.gitignore': { Icon: SiGit, color: '#f05032' },
  '.gitattributes': { Icon: SiGit, color: '#f05032' }
}
 
const DEFAULT_FILE: IconSpec = { Icon: VscFile, color: '#9aa0a6' }
 
export function fileIcon(name: string): IconSpec {
  const byName = BY_NAME[name.toLowerCase()]
  if (byName) return byName
  const ext = name.split('.').pop()?.toLowerCase() ?? ''
  return BY_EXT[ext] ?? DEFAULT_FILE
}
 
export function folderIcon(isOpen: boolean): IconSpec {
  return { Icon: isOpen ? VscFolderOpened : VscFolder, color: '#90a4ae' }
}

The lookup checks the full filename first, so Dockerfile and .gitignore get their own mark, then falls back to the extension, then to a plain document for anything we haven't mapped. Folders return the open or closed variant from their state.

Now wire it into the Node renderer. Add two imports at the top of FileExplorer.tsx:

tsx
import { VscChevronDown, VscChevronRight } from 'react-icons/vsc'
import { fileIcon, folderIcon } from './fileIcons'

And replace the Node function with one that draws a chevron slot, the type icon, and the label:

tsx
function Node({ node, style, dragHandle }: NodeRendererProps<TreeNode>) {
  const { Icon, color } = node.isLeaf ? fileIcon(node.data.name) : folderIcon(node.isOpen)
  return (
    <div
      className="tree-row"
      style={style}
      ref={dragHandle}
      onClick={() => !node.isLeaf && node.toggle()}
    >
      <span className="tree-twisty">
        {!node.isLeaf && (node.isOpen ? <VscChevronDown /> : <VscChevronRight />)}
      </span>
      <Icon className="tree-type-icon" style={{ color }} />
      {node.isEditing ? (
        <input
          className="tree-input"
          autoFocus
          defaultValue={node.data.name}
          onBlur={() => node.reset()}
          onKeyDown={(e) => {
            if (e.key === 'Escape') node.reset()
            if (e.key === 'Enter') node.submit(e.currentTarget.value)
          }}
        />
      ) : (
        <span className="tree-label">{node.data.name}</span>
      )}
    </div>
  )
}

Picking the icon is the one call at the top: a folder gets the open or closed folder, a file gets its type icon, and the brand color rides along as an inline style. The chevron lives in a fixed-width slot even on files that don't have one, so every type icon lines up in the same column whatever its depth.

A little CSS for the two slots. The .tree-icon rule from Part 3 is no longer used once the glyphs are gone, so you can drop it.

css
/* The folder twisty (chevron) sits in a fixed slot so files, which have no
   chevron, still line their type icon up under the folder's. */
.tree-twisty {
  width: 16px;
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--text-dim);
  font-size: 16px;
}
 
/* react-icons render an <svg> sized 1em, so font-size controls the glyph and
   the inline color we pass fills it. */
.tree-type-icon {
  flex: none;
  font-size: 14px;
  display: inline-flex;
  align-items: center;
}

Open a folder now and the tree reads like a real editor. Each file carries its language's colored mark, .tsx files show the React atom, and folders flip between an open and closed icon as you click them. Adding a new file type is one row in BY_EXT.

Feature 1: A command palette

The command palette is the single most VS Code thing there is: one keystroke, a fuzzy search box, every command in the app. The trick is that it's just a filtered list, so we build one reusable component and power both the palette and Quick Open with it.

src/renderer/src/palette/Palette.tsx:

tsx
import { useEffect, useMemo, useRef, useState } from 'react'
 
export interface PaletteItem {
  id: string
  label: string
  detail?: string
  run: () => void
}
 
// Subsequence match: every query char must appear in order. Consecutive hits
// score higher, so "App" ranks "App.tsx" above "a-b-c-p-p".
function score(query: string, text: string): number {
  const q = query.toLowerCase()
  const t = text.toLowerCase()
  let qi = 0
  let points = 0
  let last = -2
  for (let ti = 0; ti < t.length && qi < q.length; ti++) {
    if (t[ti] === q[qi]) {
      points += ti === last + 1 ? 2 : 1
      last = ti
      qi++
    }
  }
  return qi === q.length ? points : -1
}
 
interface Props {
  items: PaletteItem[]
  placeholder: string
  onClose: () => void
}
 
export default function Palette({ items, placeholder, onClose }: Props) {
  const [query, setQuery] = useState('')
  const [active, setActive] = useState(0)
  const inputRef = useRef<HTMLInputElement>(null)
 
  useEffect(() => {
    inputRef.current?.focus()
  }, [])
 
  const results = useMemo(() => {
    if (!query) return items.slice(0, 50)
    return items
      .map((item) => ({ item, s: score(query, item.label) }))
      .filter((r) => r.s >= 0)
      .sort((a, b) => b.s - a.s)
      .slice(0, 50)
      .map((r) => r.item)
  }, [items, query])
 
  useEffect(() => {
    setActive(0)
  }, [query])
 
  function onKeyDown(e: React.KeyboardEvent): void {
    if (e.key === 'ArrowDown') {
      e.preventDefault()
      setActive((a) => Math.min(a + 1, results.length - 1))
    } else if (e.key === 'ArrowUp') {
      e.preventDefault()
      setActive((a) => Math.max(a - 1, 0))
    } else if (e.key === 'Enter') {
      e.preventDefault()
      results[active]?.run()
      onClose()
    } else if (e.key === 'Escape') {
      e.preventDefault()
      onClose()
    }
  }
 
  return (
    <div className="palette-overlay" onMouseDown={onClose}>
      <div className="palette" onMouseDown={(e) => e.stopPropagation()}>
        <input
          ref={inputRef}
          className="palette-input"
          placeholder={placeholder}
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={onKeyDown}
        />
        <ul className="palette-list">
          {results.map((item, i) => (
            <li
              key={item.id}
              className={'palette-item' + (i === active ? ' is-active' : '')}
              onMouseEnter={() => setActive(i)}
              onMouseDown={() => {
                item.run()
                onClose()
              }}
            >
              <span className="palette-label">{item.label}</span>
              {item.detail && <span className="palette-detail">{item.detail}</span>}
            </li>
          ))}
        </ul>
      </div>
    </div>
  )
}

In App, hold which palette is open and build the command list from the actions we already have. Each command is a label and a function:

tsx
const [palette, setPalette] = useState<'commands' | 'files' | null>(null)
 
const commands: PaletteItem[] = [
  { id: 'sidebar', label: 'View: Toggle Sidebar', run: () => setSidebarVisible((v) => !v) },
  { id: 'terminal', label: 'View: Toggle Terminal', run: () => setTerminalVisible((v) => !v) },
  { id: 'wrap', label: 'View: Toggle Word Wrap', run: () => setWordWrap((v) => !v) },
  { id: 'save', label: 'File: Save', run: () => activePath && saveFile(activePath) },
  { id: 'close', label: 'File: Close Tab', run: () => activePath && closeTab(activePath) },
  { id: 'open', label: 'File: Open Folder', run: openFolder }
]

Open it from the keyboard handler with Cmd/Ctrl+Shift+P, and render it when active:

tsx
// in the keydown handler:
if (mod && e.shiftKey && (e.key === 'p' || e.key === 'P')) {
  e.preventDefault()
  setPalette('commands')
}
 
// near the end of App's JSX:
{palette === 'commands' && (
  <Palette
    items={commands}
    placeholder="Type a command"
    onClose={() => setPalette(null)}
  />
)}

That's a working command palette. Every action in the app is now reachable by name, and adding a new command is one line in the array.

Feature 2: Quick Open

Quick Open is Cmd/Ctrl+P: fuzzy-search every file in the project and hit Enter to open it. Because we already have the palette and the workspace lives in App, this is mostly data. We flatten the loaded tree into a list of files and turn each into a palette item.

tsx
function flattenFiles(nodes: TreeNode[]): TreeNode[] {
  return nodes.flatMap((n) => (n.children ? flattenFiles(n.children) : [n]))
}
 
const fileItems: PaletteItem[] = useMemo(() => {
  if (!tree || !rootDir) return []
  return flattenFiles(tree).map((f) => ({
    id: f.id,
    label: f.name,
    detail: f.id.slice(rootDir.length + 1), // path relative to the root
    run: () => openFile(f.id)
  }))
}, [tree, rootDir])

Wire the shortcut and render, reusing the same component:

tsx
// in the keydown handler, before the Shift+P case:
if (mod && !e.shiftKey && (e.key === 'p' || e.key === 'P')) {
  e.preventDefault()
  setPalette('files')
}
 
// in JSX:
{palette === 'files' && (
  <Palette
    items={fileItems}
    placeholder="Search files by name"
    onClose={() => setPalette(null)}
  />
)}

Two features, one component. The detail line shows the relative path so two files with the same name are still distinguishable, exactly like VS Code.

Some shared styling for both palettes:

css
.palette-overlay {
  position: fixed;
  inset: 0;
  display: flex;
  justify-content: center;
  padding-top: 80px;
  background: rgba(0, 0, 0, 0.3);
  z-index: 100;
}
 
.palette {
  width: 600px;
  max-width: 90vw;
  max-height: 50vh;
  display: flex;
  flex-direction: column;
  background: #252526;
  border: 1px solid #454545;
  border-radius: 6px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
  overflow: hidden;
}
 
.palette-input {
  padding: 10px 12px;
  background: #3c3c3c;
  border: none;
  border-bottom: 1px solid #454545;
  color: var(--text);
  font-size: 14px;
  outline: none;
}
 
.palette-list { list-style: none; margin: 0; padding: 4px; overflow-y: auto; }
 
.palette-item {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 6px 10px;
  border-radius: 4px;
  cursor: pointer;
}
.palette-item.is-active { background: #04395e; }
.palette-label { color: var(--text); }
.palette-detail { color: var(--text-dim); font-size: 12px; }

Feature 3: A status bar

The status bar is the strip along the bottom showing the active file's language, the cursor position, and whether there are unsaved changes. The only new wiring it needs is the cursor position, which lives inside Monaco, so we surface it through the editor's onMount callback.

In EditorPane, accept an onCursorChange prop and subscribe to Monaco's cursor event:

tsx
import Editor from '@monaco-editor/react'
import type { editor } from 'monaco-editor'
import { languageForPath } from './languages'
import type { OpenFile } from '../App'
 
interface Props {
  file: OpenFile | null
  wordWrap: boolean
  onChange: (content: string) => void
  onCursorChange: (line: number, column: number) => void
}
 
export default function EditorPane({ file, wordWrap, onChange, onCursorChange }: Props) {
  if (!file) {
    return <p className="placeholder">Open a file to start editing</p>
  }
 
  function handleMount(instance: editor.IStandaloneCodeEditor): void {
    instance.onDidChangeCursorPosition((e) =>
      onCursorChange(e.position.lineNumber, e.position.column)
    )
  }
 
  return (
    <Editor
      height="100%"
      theme="vs-dark"
      path={file.path}
      language={languageForPath(file.path)}
      value={file.content}
      onMount={handleMount}
      onChange={(next) => onChange(next ?? '')}
      options={{
        fontSize: 13,
        fontFamily: 'ui-monospace, monospace',
        minimap: { enabled: true },
        scrollBeyondLastLine: false,
        automaticLayout: true,
        wordWrap: wordWrap ? 'on' : 'off',
        tabSize: 2,
        renderWhitespace: 'selection',
        smoothScrolling: true,
        cursorBlinking: 'smooth'
      }}
    />
  )
}

The onMount callback is the same hook we flagged all the way back when discussing Monaco: it hands you the underlying editor instance, and from there its imperative API, like onDidChangeCursorPosition, is yours.

A small status bar component:

tsx
import { languageForPath } from '../editor/languages'
import type { OpenFile } from '../App'
 
interface Props {
  file: OpenFile | null
  cursor: { line: number; column: number }
}
 
export default function StatusBar({ file, cursor }: Props) {
  if (!file) return <footer className="status-bar" />
  const dirty = file.content !== file.savedContent
  return (
    <footer className="status-bar">
      <span>{languageForPath(file.path)}</span>
      <span>
        Ln {cursor.line}, Col {cursor.column}
      </span>
      <span>{dirty ? 'Unsaved' : 'Saved'}</span>
    </footer>
  )
}

In App, hold the cursor and place the bar below the resizable area. The status bar sits outside the Allotment split, so we wrap the splits in a flex column:

tsx
const [cursor, setCursor] = useState({ line: 1, column: 1 })
 
return (
  <div className="app">
    <div className="workspace">
      <Allotment proportionalLayout={false}>
        {/* ...the sidebar and editor/terminal panes from Part 6... */}
        {/* pass onCursorChange to EditorPane: */}
        {/* onCursorChange={(line, column) => setCursor({ line, column })} */}
      </Allotment>
    </div>
    <StatusBar file={activeFile} cursor={cursor} />
    {/* palettes render here */}
  </div>
)
css
.app { display: flex; flex-direction: column; }
.workspace { flex: 1; min-height: 0; }
 
.status-bar {
  display: flex;
  gap: 16px;
  padding: 2px 12px;
  background: #007acc;
  color: #fff;
  font-size: 12px;
}

Now the bar tracks your cursor as you move it, names the language of the active file, and flips to "Unsaved" the moment you type. It reads from the same derived dirty state we built in Part 5, so there's nothing new to keep in sync.

Feature 4: Language services and autocomplete

Since Part 2 we've run Monaco with a single base worker, the editor.worker that handles core editing and nothing language-aware. Part 6 turned on tokenization, the coloring, which runs on the main thread from a built-in grammar. Autocomplete, hover, and signature help are the half we deferred: they're computed by language-specific workers, and Monaco ships several of them in the box.

Wiring those workers is the whole feature. Monaco bundles a full TypeScript language service, plus JSON, CSS, and HTML services, each compiled to its own worker, and none of them need an external process. We just have to answer Monaco's getWorker call with the right worker for the language it's asking about.

Back in setupMonaco.ts from Part 2, replace the single-worker setup with one that switches on the label Monaco passes:

ts
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import { loader } from '@monaco-editor/react'
 
self.MonacoEnvironment = {
  getWorker(_workerId, label) {
    if (label === 'json') return new jsonWorker()
    if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker()
    if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker()
    if (label === 'typescript' || label === 'javascript') return new tsWorker()
    return new editorWorker()
  }
}
 
loader.config({ monaco })

The label is the language id Monaco derives from each model, the same ids the EXT_TO_LANG map in Part 6 produces. Focus a .ts file and Monaco asks for the typescript worker; a .css file asks for css; everything else falls through to the base editor.worker, exactly as before. Vite bundles each worker the way it bundled the first, so this stays offline.

Open a .ts or .js file now and it comes alive. Start typing and you get completions, type . after a value and you get its members, hover a symbol and you get its type. That's the real TypeScript service running in a worker, the same one VS Code uses, with no setup beyond the imports above.

Telling the TypeScript service how to read your files

The TypeScript worker has one default worth changing. It runs full semantic analysis, which includes resolving every import against node_modules. Our editor opens loose files with no project behind them, so those imports resolve to nothing and the service paints "cannot find module" squiggles across files that are perfectly fine. The completions are real; the errors are an artifact of having no project.

We turn the diagnostics down while keeping the smarts. Add a configuration block below the worker setup in the same file, where monaco is already imported. Monaco 0.55 moved these defaults to a top-level monaco.typescript namespace; older versions kept them under monaco.languages.typescript, which still appears in a lot of guides.

ts
const ts = monaco.typescript
 
const compilerOptions = {
  target: ts.ScriptTarget.ESNext,
  module: ts.ModuleKind.ESNext,
  moduleResolution: ts.ModuleResolutionKind.NodeJs,
  jsx: ts.JsxEmit.React,
  allowNonTsExtensions: true,
  allowJs: true,
  esModuleInterop: true
}
 
const diagnostics = { noSemanticValidation: true, noSyntaxValidation: false }
 
ts.typescriptDefaults.setCompilerOptions(compilerOptions)
ts.typescriptDefaults.setDiagnosticsOptions(diagnostics)
ts.javascriptDefaults.setCompilerOptions(compilerOptions)
ts.javascriptDefaults.setDiagnosticsOptions(diagnostics)

noSemanticValidation: true drops the cross-file errors that don't make sense without a real project, while noSyntaxValidation: false keeps the squiggles that catch genuine typos, a stray bracket or an unterminated string. The compiler options tell the service to parse modern syntax and JSX, so a .tsx file reads correctly. Autocomplete, hover, and signature help all keep working, because none of them depend on the diagnostics being on.

This is as far as the in-the-box services go, and it covers the languages most projects live in. The JSON worker validates against schemas and completes keys, the CSS worker knows properties and values, and the HTML worker completes tags and attributes, each for free. What you don't get this way is IntelliSense for Python, Rust, Go, and the rest, none of which have a bundled Monaco worker. Reaching them means running their real language servers in the main process and speaking LSP, which is the first thread in the map below.

Future work: the rest of VS Code

What we've built is a real editor, but VS Code is enormous. Here's a map of what's left, grouped so you can pick a thread and pull. Each builds on the same foundation: privileged work in the main process behind the IPC bridge, UI in React.

Editing

  • Find and replace across files (a fs:search handler plus a results view)
  • IntelliSense for the languages without a bundled Monaco worker (Feature 4 gave us JS, TS, JSON, CSS, and HTML for free; Python, Rust, Go, and the rest mean running their language servers in the main process and speaking LSP)
  • Project-wide diagnostics, including the semantic errors we turned off for loose files (the same language servers, fed a real project model)
  • Go to definition, hover, find references, rename symbol
  • Format on save, and a formatter per language
  • Code folding regions, and a fold/unfold all command
  • Snippets

Workbench

  • An activity bar and multiple sidebar views (explorer, search, source control)
  • A bottom panel with tabs (problems, output, debug console)
  • Breadcrumbs above the editor
  • Split editor groups, so two files sit side by side
  • Editor tabs that reorder by drag, and a "recently used" order
  • Zen mode, and a persisted window layout that restores on launch

Files and workspace

  • A right-click context menu on the tree (new file, new folder, delete, duplicate)
  • A file watcher (fs.watch streamed over IPC) so the tree updates on external changes
  • Move to trash instead of permanent delete
  • Multi-root workspaces
  • Recent folders, and reopening the last session

Source control

  • Git status decorations on the tree and in the gutter
  • A source control view to stage, unstage, and commit
  • Inline diffs and a side-by-side diff editor
  • Blame annotations

Terminal

  • Multiple terminals with tabs
  • Split terminals
  • A terminal that opens in the active file's folder

Settings and theming

  • A settings UI backed by a JSON file (one more IPC handler to read and write it)
  • A keybindings editor
  • Light, dark, and custom color themes
  • Per-workspace settings

Extensions

  • An extension host that loads third-party code safely
  • A contribution model for commands, views, and languages

Every item on that list is a variation on a move you've already made. A context menu is the command palette pointed at the tree. A file watcher is the terminal's streaming IPC in one direction. A settings file is fs:readFile and fs:writeFile with a schema. The diff editor is two Monaco models. Source control is shelling out to git in the main process and decorating the UI with what comes back.

That's the real lesson of the series. A code editor looks impossibly large from the outside, but it's a small number of patterns repeated with discipline. You've now built the patterns. The rest is just deciding what to ask the host for next.