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 import Raxol.Guards @impl Phoenix.LiveView def mount(_params, session, socket) do # In test environment, always use connected mode to ensure assigns are available if Mix.env() == :test do require Logger Logger.debug( "RaxolWeb.TerminalLive: Using mount_connected for test environment" ) mount_connected(session, socket) else if connected?(socket) do require Logger Logger.debug( "RaxolWeb.TerminalLive: Using mount_connected for connected socket" ) mount_connected(session, socket) else require Logger Logger.debug( "RaxolWeb.TerminalLive: Using mount_disconnected for disconnected socket" ) mount_disconnected(socket) end end end def index(assigns, _params) do assigns end defp mount_connected(session, socket) do require Logger Logger.debug( "RaxolWeb.TerminalLive: mount_connected called with session: #{inspect(session)}" ) # Initialize cache table initialize_cache() session_id = generate_session_id() user_id = session["user_id"] || session_id topic = "terminal:" <> session_id Logger.debug("RaxolWeb.TerminalLive: Initializing emulator...") emulator = initialize_emulator(session) renderer = Raxol.Terminal.Renderer.new(emulator.main_screen_buffer) Logger.debug("RaxolWeb.TerminalLive: Setting up presence...") setup_presence(topic, user_id) presences = Presence.list(topic) users = Map.keys(presences) cursors = %{user_id => %{x: 0, y: 0, visible: true}} Logger.debug("RaxolWeb.TerminalLive: Initializing socket...") socket = initialize_socket( socket, session_id, emulator, renderer, topic, user_id, users, cursors ) Logger.debug( "RaxolWeb.TerminalLive: mount_connected returning socket with assigns: #{inspect(socket.assigns)}" ) {:ok, socket, temporary_assigns: [terminal_html: ""]} end defp mount_disconnected(socket) do session_id = "disconnected-session" emulator = EmulatorStruct.new(80, 24, scrollback: 1000) renderer = Raxol.Terminal.Renderer.new(emulator.main_screen_buffer) users = [] cursors = %{} socket = socket |> assign(:session_id, session_id) |> assign(:terminal_html, "") |> assign(:cursor, %{x: 0, y: 0, visible: true}) |> assign(:dimensions, %{width: 80, height: 24}) |> assign(:scroll_offset, 0) |> assign( :theme, Raxol.UI.Theming.Theme.get(Raxol.UI.Theming.Theme.current()) ) |> assign(:connected, false) |> assign(:emulator, emulator) |> assign(:renderer, renderer) |> assign(:scrollback_size, 0) |> assign(:scrollback_limit, 1000) |> assign(:users, users) |> assign(:presence_topic, nil) |> assign(:user_id, nil) |> assign(:cursors, cursors) {:ok, socket} 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 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 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 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 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 def handle_event("scroll", %{"offset" => offset}, socket) do offset = if 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 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 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 def handle_event("scroll_up", _params, socket) do emulator = socket.assigns.emulator page_size = emulator.height handle_scroll_update(socket, -page_size) end def handle_event("scroll_down", _params, socket) do emulator = socket.assigns.emulator page_size = emulator.height handle_scroll_update(socket, page_size) end def handle_event("set_scrollback_limit", %{"limit" => limit}, socket) do limit = if 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 def handle_event("theme", %{"theme" => theme}, socket) do socket = assign(socket, theme: theme) {:noreply, push_event(socket, "terminal_theme", %{theme: theme})} end 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 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 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_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 handle_info(message, socket) do # Catch-all clause for unexpected messages require Logger Logger.debug( "RaxolWeb.TerminalLive received unexpected message: #{inspect(message)}" ) {:noreply, socket} end @impl Phoenix.LiveView def terminate(_reason, socket) do # Clean up Presence tracking and PubSub subscriptions if socket.assigns.presence_topic && socket.assigns.user_id do require Logger Logger.debug( "RaxolWeb.TerminalLive: Cleaning up presence and pubsub for topic: #{socket.assigns.presence_topic}" ) # Untrack from Presence Presence.untrack( self(), socket.assigns.presence_topic, socket.assigns.user_id ) # Unsubscribe from PubSub PubSub.unsubscribe(Raxol.PubSub, socket.assigns.presence_topic) end :ok end @impl Phoenix.LiveView def render(assigns) do ~H"""