ElixirMarkdownEditor
Phoenix LiveView components for file browsing and editing with Monaco editor.
Provides a ready-to-use file tree, code editor, and integrated file browser backed by a pluggable storage adapter. Drop it into any Phoenix LiveView app — you control where files live (filesystem, S3, database, etc.) by implementing a simple behaviour.
Features
- File tree — recursive, collapsible directory tree with keyboard navigation
- Code editor — Monaco editor wrapper with automatic language detection for 60+ file types
- Integrated browser — side-by-side tree + editor component with dirty tracking and save controls
- Pluggable storage — implement the
ElixirMarkdownEditor.FileStorebehaviour to back the editor with any storage - Light theme — ships with Monaco's
vs(light) theme by default - Accessible — keyboard navigation (arrow keys, Enter) and ARIA roles on the file tree
Installation
Add to your mix.exs:
def deps do
[
{:elixir_markdown_editor, "~> 0.1.0"}
]
endOr from GitHub:
def deps do
[
{:elixir_markdown_editor, github: "Founding-Developers/elixir-markdown-editor"}
]
endSetup
1. Implement the FileStore behaviour
Create an adapter module that implements ElixirMarkdownEditor.FileStore:
defmodule MyApp.LocalFileStore do
@behaviour ElixirMarkdownEditor.FileStore
@impl true
def list_files(scope) do
# Return {:ok, [file_entry]} where file_entry is:
# %{name: "file.md", path: "docs/file.md", type: :file, children: nil}
# %{name: "docs", path: "docs", type: :directory, children: [...]}
end
@impl true
def read_file(scope, path) do
# Return {:ok, content} or {:error, reason}
end
@impl true
def write_file(scope, path, content) do
# Return :ok or {:error, reason}
end
@impl true
def create_file(scope, path), do: # ...
@impl true
def delete_file(scope, path), do: # ...
@impl true
def rename_file(scope, old_path, new_path), do: # ...
end
The scope parameter is opaque — define it however your app needs (org ID, user context, S3 prefix, etc.).
2. Configure the adapter
In config/config.exs:
config :elixir_markdown_editor, :file_store, MyApp.LocalFileStore3. Import JS hooks
In your assets/js/app.js:
import { FileTreeHook } from "elixir_markdown_editor/assets/js/index.js";
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...otherHooks, FileTreeHook }
});Note: If installed as a dependency, the path will be
../deps/elixir_markdown_editor/assets/js/index.js. If used as an umbrella app, adjust the relative path accordingly.
4. Import CSS
In your assets/css/app.css:
@source "../deps/elixir_markdown_editor/lib";
@import "../deps/elixir_markdown_editor/assets/css/elixir_markdown_editor.css";Usage
Integrated file browser
The simplest way to use the library — a side-by-side tree + editor:
defmodule MyAppWeb.FilesLive do
use MyAppWeb, :live_view
use ElixirMarkdownEditor.Components
alias ElixirMarkdownEditor.Components.FileEditor
@editor_path FileEditor.editor_model_path()
@impl true
def mount(_params, _session, socket) do
store = ElixirMarkdownEditor.file_store()
scope = %{root: "/path/to/files"}
{:ok, files} = store.list_files(scope)
{:ok,
socket
|> assign(:scope, scope)
|> assign(:files, files)
|> assign(:selected_path, nil)
|> assign(:selected_content, "")
|> assign(:expanded_dirs, MapSet.new())
|> assign(:dirty, false)
|> assign(:_editor_mounted, false)
|> assign(:_ignore_next_change, false)}
end
@impl true
def handle_event("eme:select_file", %{"path" => path}, socket) do
store = ElixirMarkdownEditor.file_store()
case store.read_file(socket.assigns.scope, path) do
{:ok, content} ->
language = ElixirMarkdownEditor.Language.detect(path)
socket =
socket
|> assign(:selected_path, path)
|> assign(:selected_content, content)
|> assign(:dirty, false)
socket =
if socket.assigns._editor_mounted do
socket
|> assign(:_ignore_next_change, true)
|> LiveMonacoEditor.set_value(content, to: @editor_path)
|> LiveMonacoEditor.change_language(language, to: @editor_path)
else
assign(socket, :_editor_mounted, true)
end
{:noreply, socket}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to open file")}
end
end
def handle_event("eme:toggle_dir", %{"path" => path}, socket) do
expanded = socket.assigns.expanded_dirs
updated =
if MapSet.member?(expanded, path),
do: MapSet.delete(expanded, path),
else: MapSet.put(expanded, path)
{:noreply, assign(socket, :expanded_dirs, updated)}
end
def handle_event("eme:file_changed", %{"value" => content}, socket) do
if socket.assigns._ignore_next_change do
{:noreply,
socket
|> assign(:selected_content, content)
|> assign(:_ignore_next_change, false)}
else
{:noreply,
socket
|> assign(:selected_content, content)
|> assign(:dirty, true)}
end
end
def handle_event("eme:file_saved", %{"path" => _path}, socket) do
store = ElixirMarkdownEditor.file_store()
path = socket.assigns.selected_path
case store.write_file(socket.assigns.scope, path, socket.assigns.selected_content) do
:ok -> {:noreply, assign(socket, :dirty, false)}
{:error, reason} -> {:noreply, put_flash(socket, :error, "Save failed: #{inspect(reason)}")}
end
end
@impl true
def render(assigns) do
~H"""
<.file_browser
id="my-files"
files={@files}
scope={@scope}
selected_path={@selected_path}
selected_content={@selected_content}
expanded_dirs={@expanded_dirs}
dirty={@dirty}
on_save="eme:file_saved"
/>
"""
end
endImportant: The
_ignore_next_changeflag preventsLiveMonacoEditor.set_valuefrom triggering a false dirty state. When you programmatically set editor content, Monaco fires a change event — the flag ensures it's ignored.
Individual components
You can also use the tree and editor separately:
<%!-- Just the file tree --%>
<.file_tree
id="my-tree"
files={@files}
selected_path={@selected_path}
expanded_dirs={@expanded_dirs}
/>
<%!-- Just the editor --%>
<.file_editor
id="my-editor"
path={@selected_path}
value={@selected_content}
dirty={@dirty}
on_save="save_file"
/>Components
| Component | Description |
|---|---|
file_browser | Integrated side-by-side tree + editor |
file_tree | Standalone collapsible file tree |
file_editor | Monaco editor wrapper with language detection |
Events
| Event | Payload | Description |
|---|---|---|
eme:select_file | %{"path" => path} | File clicked in tree |
eme:toggle_dir | %{"path" => path} | Directory expanded/collapsed |
eme:file_changed | %{"value" => content} | Editor content changed |
eme:file_saved | %{"path" => path} | Save button clicked |
All event names are configurable via component attributes.
Language detection
ElixirMarkdownEditor.Language.detect/1 maps file extensions to Monaco language IDs. Supports 60+ file types including Elixir, JavaScript, TypeScript, Python, Ruby, Rust, Go, and more. Falls back to "plaintext" for unknown extensions.
License
MIT License. See LICENSE for details.