defmodule Omni.UI.FilesComponent do
@moduledoc """
A LiveComponent that renders the files panel.
Receives `session_id` from the parent LiveView and manages all file
state internally: the file index, active selection, content loading,
and token signing.
## Assigns from parent
* `session_id` — the current session ID (triggers rescan on change)
## Actions via `send_update`
* `action: :rescan` — rescans the files directory (e.g. after a
tool result creates or modifies a file)
* `action: {:view, filename}` — opens the named file in the panel
(e.g. when the user clicks an inline file tool-use button)
## View modes
The active file's MIME type determines its default view mode. A separate
`view_source` boolean can override the default to `:source` for toggleable types.
* `:iframe` — (`text/html`, `application/pdf`) served from the file
Plug route; HTML gets `sandbox="allow-scripts"`
* `:markdown` — (`text/markdown`) MDEx-rendered HTML with typography styles
* `:media` — (`image/*`) centered `<img>` tag served from the Plug route
* `:source` — (`text/*`, `application/json`, and other text-like types)
syntax-highlighted source via Lumis
* `:download` — (everything else) download link
HTML, Markdown, and SVG files support a **Preview / Code toggle** in the
file bar, switching between the default view and `:source`.
"""
use Phoenix.LiveComponent
import Omni.UI.CoreUI
import Omni.UI.FilesUI
import Omni.UI.Helpers, only: [cls: 1, highlight_code: 2, md_styles: 0, to_md: 1]
alias Omni.Tools.Files.FS
alias Omni.UI.Files.URL
@iframe_mime_types ~w(
text/html application/pdf
)
@text_like_types ~w(
application/json application/javascript application/xml
application/x-yaml application/x-sh application/sql
)
attr :session_id, :string, default: nil
@impl true
def render(assigns) do
~H"""
<section class="omni-ui h-full">
<.panel body_class={cls(["overflow-auto" | md_styles()])}>
<:header>
<.files_panel_header
file={@files[@active_file]}
view_source={@view_source}
token={@token}
target={@myself} />
</:header>
<%= if @active_file do %>
<.file_view
file={@files[@active_file]}
content={@content}
view={@view}
token={@token}
target={@myself} />
<% else %>
<.file_list files={@files} error={@error} target={@myself} />
<% end %>
</.panel>
</section>
"""
end
@impl true
def mount(socket) do
{:ok,
assign(socket,
files: %{},
active_file: nil,
content: nil,
error: nil,
view: nil,
view_source: false,
session_id: nil,
token: nil
)}
end
@impl true
def update(%{action: :rescan}, socket) do
files = scan_files(socket.assigns.session_id)
case Map.get(files, socket.assigns.active_file) do
nil ->
{:ok, assign(socket, files: files, active_file: nil, error: nil)}
_ ->
socket =
socket
|> assign(files: files, error: nil)
|> assign_content()
{:ok, socket}
end
end
def update(%{action: {:view, filename}}, socket) do
if Map.has_key?(socket.assigns.files, filename) do
socket =
socket
|> assign(active_file: filename, view_source: false, error: nil)
|> assign_content()
{:ok, socket}
else
{:ok, assign(socket, active_file: nil, error: "\"#{filename}\" has been deleted.")}
end
end
def update(%{session_id: new_session_id} = assigns, socket) do
old_session_id = socket.assigns.session_id
socket = assign(socket, assigns)
cond do
new_session_id == old_session_id ->
{:ok, socket}
new_session_id == nil ->
{:ok,
assign(socket,
files: %{},
active_file: nil,
content: nil,
view: nil,
view_source: false,
token: nil
)}
true ->
{:ok,
assign(socket,
files: scan_files(new_session_id),
active_file: nil,
content: nil,
view: nil,
view_source: false,
token: URL.sign_token(socket, new_session_id)
)}
end
end
@impl true
def handle_event("open", %{"filename" => filename}, socket) do
socket =
socket
|> assign(active_file: filename, view_source: false, error: nil)
|> assign_content()
{:noreply, socket}
end
def handle_event("toggle", _, socket) do
socket =
socket
|> assign(view_source: !socket.assigns.view_source)
|> assign_content()
{:noreply, socket}
end
def handle_event("close", _, socket) do
{:noreply,
assign(socket,
active_file: nil,
content: nil,
view: nil,
view_source: false
)}
end
# ── Helpers ──────────────────────────────────────────────────────
defp assign_content(socket) do
file = socket.assigns.files[socket.assigns.active_file]
view =
case socket.assigns.view_source do
true -> :source
_ -> view_mode(file.media_type)
end
assign(socket,
content: load_content(view, file, socket.assigns.session_id),
view: view
)
end
defp view_mode(mime_type) when mime_type in @iframe_mime_types, do: :iframe
defp view_mode("text/markdown"), do: :markdown
defp view_mode(mime_type) when mime_type in @text_like_types, do: :source
defp view_mode(mime_type) do
cond do
String.starts_with?(mime_type, "image/") -> :media
String.starts_with?(mime_type, "text/") -> :source
true -> :download
end
end
defp load_content(:markdown, file, session_id) do
{:ok, data} = FS.read(session_fs(session_id), file.filename)
to_md(data)
end
defp load_content(:source, file, session_id) do
{:ok, code} = FS.read(session_fs(session_id), file.filename)
highlight_code(code, file.filename)
end
defp load_content(_view, _file, _session_id), do: nil
defp scan_files(session_id) do
{:ok, entries} = FS.list(session_fs(session_id))
Map.new(entries, &{&1.filename, &1})
end
defp session_fs(session_id) do
FS.new(base_dir: Omni.UI.Sessions.session_files_dir(session_id), nested: false)
end
end