The pad from the previous chapters works with a single experiment in memory. In this chapter we add file management: save experiments as .ex files, list them in a sidebar, create new ones, switch between them, and delete the ones you no longer need.

Along the way we will learn about dynamic list rendering, scoped IDs, text_input, checkbox, and the Command.focus/1 command.

Saving experiments to files

Experiments are plain Elixir source files. We will store them in priv/experiments/, a directory outside the compilation paths so they do not get compiled by Mix at startup. Each file is a module with a view/0 function, exactly like the starter code from chapter 4.

These are standard Elixir file operations, not Plushie concepts:

@experiments_dir "priv/experiments"

defp list_experiments do
  File.mkdir_p!(@experiments_dir)

  @experiments_dir
  |> File.ls!()
  |> Enum.filter(&String.ends_with?(&1, ".ex"))
  |> Enum.sort()
end

defp save_experiment(name, source) do
  File.mkdir_p!(@experiments_dir)
  File.write!(Path.join(@experiments_dir, name), source)
end

defp load_experiment(name) do
  Path.join(@experiments_dir, name) |> File.read!()
end

defp delete_experiment(name) do
  Path.join(@experiments_dir, name) |> File.rm!()
end

We will call these from update/2 when the user interacts with the file management UI.

Updating the model

The model needs a few new fields:

%{
  source: "...",
  preview: nil,
  error: nil,
  event_log: [],
  # New fields
  files: [],              # list of filenames in experiments dir
  active_file: nil,       # currently selected filename, or nil
  new_name: "",           # text input for creating new experiments
  auto_save: false        # auto-save toggle (wired up in chapter 10)
}

In init/1, we load the file list and optionally load the first file:

def init(_opts) do
  files = list_experiments()
  {source, active} = case files do
    [first | _] -> {load_experiment(first), first}
    [] -> {@starter_code, nil}
  end

  model = %{
    source: source,
    preview: nil,
    error: nil,
    event_log: [],
    files: files,
    active_file: active,
    new_name: "",
    auto_save: false
  }

  case compile_preview(source) do
    {:ok, tree} -> %{model | preview: tree}
    {:error, msg} -> %{model | error: msg}
  end
end

Dynamic lists with keyed_column

To display the file list, we need to render a dynamic list of items. The for comprehension works inside do-blocks just like in regular Elixir:

column spacing: 4 do
  for file <- model.files do
    button(file, file)
  end
end

This works, but there is a subtlety. When you add or remove a file, column matches children to their previous state by position. If you add a file at the top of the list, every child shifts down one position. The second file inherits the first file's widget state (focus, scroll position, text cursor), the third inherits the second's, and so on.

keyed_column solves this by matching children by their ID instead of position. Items keep their state no matter where they move in the list:

keyed_column spacing: 4 do
  for file <- model.files do
    button(file, file)
  end
end

Use keyed_column for any list that changes at runtime. Use column for static layouts where the children are fixed.

Scoped IDs

Each file in the list needs controls, at least a delete button. But if every delete button has id: "delete", how does update/2 know which file to delete?

This is what scoped IDs solve. When you wrap widgets in a named container, the container's ID becomes part of the scope chain. Events from widgets inside carry that scope.

container "hello.ex" do
  button("delete", "x")
end

When the delete button is clicked, the event arrives as:

%WidgetEvent{type: :click, id: "delete", scope: ["hello.ex", "main"], window_id: "main"}

The scope list contains ancestor container IDs (nearest parent first) with the window ID as the last element. You pattern match on the head to extract the file name:

def update(model, %WidgetEvent{type: :click, id: "delete", scope: [file | _]}) do
  delete_experiment(file)
  # ... update model
end

This works no matter how deeply nested the button is. Any named container between the button and the root adds its ID to the scope chain. We will use scoped IDs throughout the pad for any list where items have their own controls.

For a full treatment of scoping rules, ID resolution, and edge cases, see the Scoped IDs reference.

The file list sidebar

Here is the sidebar view. It sits to the left of the editor in a column with a fixed width:

defp file_list(model) do
  column width: 180, height: :fill, padding: 8, spacing: 8 do
    text("sidebar-title", "Experiments", size: 14)

    scrollable "file-scroll", height: :fill do
      keyed_column spacing: 2 do
        for file <- model.files do
          container file do
            row spacing: 4 do
              button(
                "select",
                file,
                width: :fill,
                style: if(file == model.active_file, do: :primary, else: :text)
              )
              button("delete", "x")
            end
          end
        end
      end
    end

    # New experiment input (covered below)
    row spacing: 4 do
      text_input("new-name", model.new_name,
        placeholder: "name.ex",
        on_submit: true
      )
    end
  end
end

Each file gets a container scoped by the filename. Inside it, a select button and a delete button. The active file is highlighted with :primary style. We will refine the styling in chapter 8.

Text input

text_input is a single-line input widget. It takes an ID, the current value (from your model), and options:

text_input("new-name", model.new_name,
  placeholder: "name.ex",
  on_submit: true
)
  • placeholder: - grey hint text shown when the input is empty.
  • on_submit: true - enables the :submit event when the user presses Enter. Without this, only :input events (on every keystroke) are emitted.

The :input event delivers the current text as value:

def update(model, %WidgetEvent{type: :input, id: "new-name", value: text}) do
  %{model | new_name: text}
end

The :submit event fires when Enter is pressed (and on_submit: true):

def update(model, %WidgetEvent{type: :submit, id: "new-name"}) do
  create_new_experiment(model)
end

Checkbox: auto-save toggle

checkbox is a boolean toggle widget:

checkbox("auto-save", model.auto_save)

It emits a :toggle event with the new boolean value:

def update(model, %WidgetEvent{type: :toggle, id: "auto-save", value: checked}) do
  %{model | auto_save: checked}
end

For now we just track the flag in the model. In chapter 10 we will wire it up with a debounce timer so saving happens automatically when the checkbox is checked and the content changes.

Commands: focus

Sometimes update/2 needs to trigger a side effect. Instead of returning a bare model, you return a {model, command} tuple.

Plushie.Command.focus/1 sets keyboard focus on a widget by its scoped path. After creating a new experiment, we want focus to jump back to the editor:

alias Plushie.Command

defp create_new_experiment(model) do
  name = String.trim(model.new_name)

  if name == "" or not String.ends_with?(name, ".ex") do
    model
  else
    template = """
    defmodule Pad.Experiments.#{name |> Path.rootname() |> Macro.camelize()} do
      import Plushie.UI

      def view do
        column padding: 16 do
          text("hello", "New experiment")
        end
      end
    end
    """

    save_experiment(name, template)
    files = list_experiments()
    model = %{model | files: files, active_file: name, source: template, new_name: ""}

    case compile_preview(template) do
      {:ok, tree} -> {%{model | preview: tree, error: nil}, Command.focus("editor")}
      {:error, msg} -> {%{model | error: msg, preview: nil}, Command.focus("editor")}
    end
  end
end

The {model, Command.focus("editor")} return tells the runtime to set focus on the widget with ID "editor" after processing the update.

Commands are pure data (Plushie.Command structs). The runtime executes them after update/2 returns. See the Commands reference for the full list.

Wiring up file switching and deletion

Add these clauses to update/2:

# Switch to a different file
def update(model, %WidgetEvent{type: :click, id: "select", scope: [file | _]}) do
  if model.active_file != nil do
    save_experiment(model.active_file, model.source)
  end

  source = load_experiment(file)
  model = %{model | active_file: file, source: source}

  case compile_preview(source) do
    {:ok, tree} -> %{model | preview: tree, error: nil}
    {:error, msg} -> %{model | error: msg, preview: nil}
  end
end

# Delete an experiment
def update(model, %WidgetEvent{type: :click, id: "delete", scope: [file | _]}) do
  delete_experiment(file)
  files = list_experiments()

  if file == model.active_file do
    case files do
      [first | _] ->
        source = load_experiment(first)
        model = %{model | files: files, active_file: first, source: source}
        case compile_preview(source) do
          {:ok, tree} -> %{model | preview: tree, error: nil}
          {:error, msg} -> %{model | error: msg, preview: nil}
        end

      [] ->
        %{model | files: [], active_file: nil, source: @starter_code, preview: nil, error: nil}
    end
  else
    %{model | files: files}
  end
end

Both use scope binding to extract the filename. File switching saves the current content first, then loads the new file.

Updating the view

The main view now includes the sidebar:

def view(model) do
  window "main", title: "Plushie Pad" do
    column width: :fill, height: :fill do
      row width: :fill, height: :fill do
        # Sidebar
        file_list(model)

        # Editor
        text_editor "editor", model.source do
          width {:fill_portion, 1}
          height :fill
          highlight_syntax "ex"
          font :monospace
        end

        # Preview
        container "preview", width: {:fill_portion, 1}, height: :fill, padding: 8 do
          if model.error do
            text("error", model.error, color: :red)
          else
            if model.preview do
              model.preview
            else
              text("placeholder", "Press Save to compile and preview")
            end
          end
        end
      end

      row padding: 4, spacing: 8 do
        button("save", "Save")
        checkbox("auto-save", model.auto_save)
        text("auto-label", "Auto-save")
      end

      scrollable "log", height: 120 do
        column spacing: 2, padding: 4 do
          for {entry, i} <- Enum.with_index(model.event_log) do
            text("log-#{i}", entry, size: 12, font: :monospace)
          end
        end
      end
    end
  end
end

The sidebar, editor, and preview sit side by side in a row. The sidebar has a fixed width of 180 pixels; the editor and preview share the remaining space equally via {:fill_portion, 1}.

The complete pad

Here is the full module with file management. This is a substantial update from chapter 5. If anything is not working, compare against this listing:

defmodule PlushiePad do
  use Plushie.App

  import Plushie.UI

  alias Plushie.Command
  alias Plushie.Event.WidgetEvent

  @experiments_dir "priv/experiments"

  @starter_code """
  defmodule Pad.Experiments.Hello do
    import Plushie.UI

    def view do
      column padding: 16, spacing: 8 do
        text("greeting", "Hello, Plushie!", size: 24)
        button("btn", "Click Me")
      end
    end
  end
  """

  def init(_opts) do
    files = list_experiments()

    {source, active} =
      case files do
        [first | _] -> {load_experiment(first), first}
        [] -> {@starter_code, nil}
      end

    model = %{
      source: source,
      preview: nil,
      error: nil,
      event_log: [],
      files: files,
      active_file: active,
      new_name: "",
      auto_save: false
    }

    case compile_preview(source) do
      {:ok, tree} -> %{model | preview: tree}
      {:error, msg} -> %{model | error: msg}
    end
  end

  def update(model, %WidgetEvent{type: :input, id: "editor", value: source}) do
    %{model | source: source}
  end

  def update(model, %WidgetEvent{type: :click, id: "save"}) do
    case compile_preview(model.source) do
      {:ok, tree} ->
        if model.active_file, do: save_experiment(model.active_file, model.source)
        %{model | preview: tree, error: nil}
      {:error, msg} ->
        %{model | error: msg, preview: nil}
    end
  end

  def update(model, %WidgetEvent{type: :input, id: "new-name", value: text}) do
    %{model | new_name: text}
  end

  def update(model, %WidgetEvent{type: :submit, id: "new-name"}) do
    create_new_experiment(model)
  end

  def update(model, %WidgetEvent{type: :toggle, id: "auto-save", value: checked}) do
    %{model | auto_save: checked}
  end

  def update(model, %WidgetEvent{type: :click, id: "select", scope: [file | _]}) do
    if model.active_file, do: save_experiment(model.active_file, model.source)
    source = load_experiment(file)
    model = %{model | active_file: file, source: source}

    case compile_preview(source) do
      {:ok, tree} -> %{model | preview: tree, error: nil}
      {:error, msg} -> %{model | error: msg, preview: nil}
    end
  end

  def update(model, %WidgetEvent{type: :click, id: "delete", scope: [file | _]}) do
    delete_experiment(file)
    files = list_experiments()

    if file == model.active_file do
      case files do
        [first | _] ->
          source = load_experiment(first)
          model = %{model | files: files, active_file: first, source: source}
          case compile_preview(source) do
            {:ok, tree} -> %{model | preview: tree, error: nil}
            {:error, msg} -> %{model | error: msg, preview: nil}
          end
        [] ->
          %{model | files: [], active_file: nil, source: @starter_code, preview: nil, error: nil}
      end
    else
      %{model | files: files}
    end
  end

  # Log everything else
  def update(model, event) do
    entry = format_event(event)
    %{model | event_log: Enum.take([entry | model.event_log], 20)}
  end

  def view(model) do
    window "main", title: "Plushie Pad" do
      column width: :fill, height: :fill do
        row width: :fill, height: :fill do
          file_list(model)

          text_editor "editor", model.source do
            width {:fill_portion, 1}
            height :fill
            highlight_syntax "ex"
            font :monospace
          end

          container "preview", width: {:fill_portion, 1}, height: :fill, padding: 8 do
            if model.error do
              text("error", model.error, color: :red)
            else
              if model.preview do
                model.preview
              else
                text("placeholder", "Press Save to compile and preview")
              end
            end
          end
        end

        row padding: 4, spacing: 8 do
          button("save", "Save")
          checkbox("auto-save", model.auto_save)
          text("auto-label", "Auto-save")
        end

        scrollable "log", height: 120 do
          column spacing: 2, padding: 4 do
            for {entry, i} <- Enum.with_index(model.event_log) do
              text("log-#{i}", entry, size: 12, font: :monospace)
            end
          end
        end
      end
    end
  end

  defp file_list(model) do
    column width: 180, height: :fill, padding: 8, spacing: 8 do
      text("sidebar-title", "Experiments", size: 14)

      scrollable "file-scroll", height: :fill do
        keyed_column spacing: 2 do
          for file <- model.files do
            container file do
              row spacing: 4 do
                button("select", file,
                  width: :fill,
                  style: if(file == model.active_file, do: :primary, else: :text)
                )
                button("delete", "x")
              end
            end
          end
        end
      end

      row spacing: 4 do
        text_input("new-name", model.new_name,
          placeholder: "name.ex",
          on_submit: true
        )
      end
    end
  end

  defp create_new_experiment(model) do
    name = String.trim(model.new_name)

    if name == "" or not String.ends_with?(name, ".ex") do
      model
    else
      template = """
      defmodule Pad.Experiments.#{name |> Path.rootname() |> Macro.camelize()} do
        import Plushie.UI

        def view do
          column padding: 16 do
            text("hello", "New experiment")
          end
        end
      end
      """

      save_experiment(name, template)
      files = list_experiments()
      model = %{model | files: files, active_file: name, source: template, new_name: ""}

      case compile_preview(template) do
        {:ok, tree} -> {%{model | preview: tree, error: nil}, Command.focus("editor")}
        {:error, msg} -> {%{model | error: msg, preview: nil}, Command.focus("editor")}
      end
    end
  end

  defp compile_preview(source) do
    case Code.string_to_quoted(source) do
      {:error, {meta, message, token}} ->
        line = Keyword.get(meta, :line, "?")
        {:error, "Line #{line}: #{message}#{token}"}

      {:ok, _ast} ->
        try do
          Code.put_compiler_option(:ignore_module_conflict, true)
          [{module, _}] = Code.compile_string(source)

          if function_exported?(module, :view, 0) do
            {:ok, module.view()}
          else
            {:error, "Module must export a view/0 function"}
          end
        rescue
          e -> {:error, Exception.message(e)}
        after
          Code.put_compiler_option(:ignore_module_conflict, false)
        end
    end
  end

  defp format_event(%mod{} = event) do
    name = mod |> Module.split() |> List.last()

    fields =
      event
      |> Map.from_struct()
      |> Enum.map(fn {k, v} -> "#{k}: #{inspect(v)}" end)
      |> Enum.join(", ")

    "%#{name}{#{fields}}"
  end

  defp list_experiments do
    File.mkdir_p!(@experiments_dir)
    @experiments_dir |> File.ls!() |> Enum.filter(&String.ends_with?(&1, ".ex")) |> Enum.sort()
  end

  defp save_experiment(name, source) do
    File.mkdir_p!(@experiments_dir)
    File.write!(Path.join(@experiments_dir, name), source)
  end

  defp load_experiment(name), do: Path.join(@experiments_dir, name) |> File.read!()
  defp delete_experiment(name), do: Path.join(@experiments_dir, name) |> File.rm!()
end

Verify it

Test the file management flow: create an experiment and switch between files:

test "create experiment and switch back" do
  type_text("#new-name", "test.ex")
  submit("#new-name")

  # New experiment appears in the sidebar
  assert_exists("#test.ex/select")

  # Switch back to the starter experiment
  click("#hello.ex/select")
  assert_text("#preview/greeting", "Hello, Plushie!")
end

This exercises scoped IDs, the create flow, file switching, and compilation. That is the core of what this chapter builds.

Try it

With the updated pad running:

  • Create a few experiments with different names. Each gets a starter template and appears in the sidebar.
  • Switch between them. Notice the editor content changes and the preview updates.
  • Delete an experiment. The sidebar updates and the next experiment loads automatically.
  • Write a gallery experiment from chapter 5 and interact with the widgets. Switch to another experiment and back. Your content is preserved because we save on switch.
  • Try the auto-save checkbox. It toggles in the model but does not save yet (that comes in chapter 10 when we learn about subscriptions).

Your pad now manages a library of experiments. Each one is a plain .ex file in priv/experiments/ that you can also open in your code editor. In the next chapter, we will improve the layout so the panes are properly sized and the spacing is consistent.


Next: Layout