defmodule RaxolWeb.TerminalLive do @moduledoc """ LiveView component for the terminal interface. This component provides: - Terminal rendering - Input handling - Resize management - Theme customization - Scroll management - Session management """ use RaxolWeb, :live_view alias RaxolWeb.Presence alias Phoenix.PubSub alias Raxol.Terminal.Emulator.Struct, as: EmulatorStruct @impl Phoenix.LiveView def mount(_params, session, socket) do if connected?(socket) do session_id = generate_session_id() user_id = session["user_id"] || session_id topic = "terminal:" <> session_id emulator = initialize_emulator(session) renderer = Raxol.Terminal.Renderer.new(emulator.main_screen_buffer) setup_presence(topic, user_id) presences = Presence.list(topic) users = Map.keys(presences) cursors = %{user_id => %{x: 0, y: 0, visible: true}} socket = initialize_socket( socket, session_id, emulator, renderer, topic, user_id, users, cursors ) {:ok, socket, temporary_assigns: [terminal_html: ""]} else {:ok, assign(socket, :connected, false)} end end @impl Phoenix.LiveView def handle_event("connect", _params, socket) do socket = socket |> assign(:connected, true) |> push_event("js-exec", %{ to: "#terminal", attr: "data-session-id", val: socket.assigns.session_id }) {:noreply, socket} end @impl Phoenix.LiveView def handle_event("disconnect", _params, socket) do _scrollback = socket.assigns.emulator.scrollback_buffer || [] { :noreply, socket |> assign(:connected, false) # |> put_session("scrollback_buffer", scrollback) # Not available in LiveView } end @impl Phoenix.LiveView def handle_event("key", %{"key" => key, "modifiers" => modifiers}, socket) do key_event = %Raxol.Terminal.Input.Event.KeyEvent{ key: key, modifiers: Enum.map(modifiers, &String.to_atom/1), timestamp: System.monotonic_time() } process_input_event(socket, key_event) end @impl Phoenix.LiveView def handle_event( "mouse", %{"x" => x, "y" => y, "button" => button}, socket ) do mouse_event = %Raxol.Terminal.Input.Event.MouseEvent{ button: String.to_atom(button), action: :press, x: x, y: y, modifiers: [], timestamp: System.monotonic_time() } process_input_event(socket, mouse_event) end @impl Phoenix.LiveView def handle_event("resize", %{"width" => width, "height" => height}, socket) do socket = assign(socket, dimensions: %{width: width, height: height}) {:noreply, push_event(socket, "terminal_resize", %{width: width, height: height})} end @impl Phoenix.LiveView def handle_event("scroll", %{"offset" => offset}, socket) do offset = if is_integer(offset), do: offset, else: String.to_integer("#{offset}") emulator = socket.assigns.emulator scrollback_size = length(emulator.scrollback_buffer || []) case {offset, scrollback_size} do {offset, 0} when offset != 0 -> {:noreply, socket} _ -> handle_scroll_update(socket, offset) end end defp handle_scroll_update(socket, offset) do emulator = socket.assigns.emulator new_emulator = if offset < 0, do: Raxol.Terminal.Commands.Screen.scroll_up(emulator, abs(offset)), else: Raxol.Terminal.Commands.Screen.scroll_down(emulator, abs(offset)) renderer = %{ socket.assigns.renderer | screen_buffer: new_emulator.main_screen_buffer } terminal_html = Raxol.Terminal.Renderer.render(renderer) new_scrollback_size = length(new_emulator.scrollback_buffer || []) at_bottom = new_scrollback_size == 0 socket = socket |> assign(:emulator, new_emulator) |> assign(:renderer, renderer) |> assign(:terminal_html, terminal_html) |> assign(:scrollback_size, new_scrollback_size) |> assign(:at_bottom, at_bottom) {:noreply, socket} end @impl Phoenix.LiveView def handle_event("scroll_to_bottom", _params, socket) do emulator = socket.assigns.emulator scrollback_size = length(emulator.scrollback_buffer || []) if scrollback_size > 0 do handle_scroll_update(socket, scrollback_size) else {:noreply, socket} end end @impl Phoenix.LiveView def handle_event("scroll_to_top", _params, socket) do emulator = socket.assigns.emulator scrollback_size = length(emulator.scrollback_buffer || []) if scrollback_size > 0 do handle_scroll_update(socket, -scrollback_size) else {:noreply, socket} end end @impl Phoenix.LiveView def handle_event("scroll_up", _params, socket) do emulator = socket.assigns.emulator page_size = emulator.height handle_scroll_update(socket, -page_size) end @impl Phoenix.LiveView def handle_event("scroll_down", _params, socket) do emulator = socket.assigns.emulator page_size = emulator.height handle_scroll_update(socket, page_size) end @impl Phoenix.LiveView def handle_event("set_scrollback_limit", %{"limit" => limit}, socket) do limit = if is_integer(limit), do: limit, else: String.to_integer("#{limit}") emulator = %{socket.assigns.emulator | scrollback_limit: limit} {:noreply, assign(socket, emulator: emulator, scrollback_limit: limit)} end @impl Phoenix.LiveView def handle_event("theme", %{"theme" => theme}, socket) do socket = assign(socket, theme: theme) {:noreply, push_event(socket, "terminal_theme", %{theme: theme})} end @impl Phoenix.LiveView def handle_event( "terminal_output", %{"html" => html, "cursor" => cursor} = payload, socket ) do # Broadcast to all users in the session except the sender PubSub.broadcast( Raxol.PubSub, socket.assigns.presence_topic, {:collab_input, payload, socket.assigns.session_id} ) {:noreply, assign(socket, terminal_html: html, cursor: cursor)} end @impl Phoenix.LiveView def handle_event( "cursor_move", %{"x" => x, "y" => y, "visible" => visible}, socket ) do cursor = %{x: x, y: y, visible: visible} PubSub.broadcast( Raxol.PubSub, socket.assigns.presence_topic, {:collab_cursor, socket.assigns.user_id, cursor} ) # Also update own cursor immediately cursors = Map.put(socket.assigns.cursors, socket.assigns.user_id, cursor) {:noreply, assign(socket, cursor: cursor, cursors: cursors)} end @impl Phoenix.LiveView def handle_info( {:collab_input, %{"html" => html, "cursor" => cursor}, sender_session_id}, socket ) do # Ignore if this message originated from this session if sender_session_id == socket.assigns.session_id do {:noreply, socket} else {:noreply, assign(socket, terminal_html: html, cursor: cursor)} end end @impl Phoenix.LiveView def handle_info(%{event: "presence_diff"}, socket) do presences = Presence.list(socket.assigns.presence_topic) users = Map.keys(presences) {:noreply, assign(socket, users: users)} end @impl Phoenix.LiveView def handle_info({:collab_cursor, user_id, cursor}, socket) do # Update the cursor for the given user cursors = Map.put(socket.assigns.cursors, user_id, cursor) {:noreply, assign(socket, cursors: cursors)} end @impl Phoenix.LiveView def render(assigns) do ~H"""