defmodule FileBrowser do @moduledoc """ Terminal file browser with preview functionality built with Raxol. Features: - Hierarchical file and directory navigation - File preview for text, images, and binary files - Search and filtering capabilities - Multiple view modes (list, tree, grid) - File operations (copy, move, delete, rename) - Bookmarks and favorites - Dual-pane interface - Keyboard shortcuts and vim-style navigation ## Usage # Start in current directory FileBrowser.start() # Start in specific directory FileBrowser.start("/path/to/directory") # Start with dual panes FileBrowser.start(mode: :dual_pane) ## Key Bindings * `j/k` - Navigate up/down in file list * `h/l` - Navigate to parent/child directory * `Enter` - Open file or enter directory * `Space` - Preview file * `/` - Start search * `n/p` - Next/previous search result * `?` - Show help * `q` - Quit application * `r` - Refresh current directory * `f` - Toggle follow mode * `v` - Change view mode * `Tab` - Switch between panes (dual pane mode) * `F2` - Rename file * `F5` - Copy file * `F6` - Move file * `Delete` - Delete file (with confirmation) """ use Raxol.UI, framework: :universal require Logger alias FileBrowser.{ State, DirectoryScanner, FilePreview, SearchEngine, FileOperations, ViewRenderer } @default_config %{ show_hidden: false, sort_by: :name, sort_direction: :asc, view_mode: :list, dual_pane: false, preview_enabled: true, follow_symlinks: false, # 1MB max_preview_size: 1_048_576, file_icons: true } # Public API @doc """ Start the file browser. """ def start(path_or_opts \\ ".", opts \\ []) def start(path, opts) when is_binary(path) do config = Keyword.get(opts, :config, @default_config) start_path = Path.expand(path) unless File.exists?(start_path) and File.dir?(start_path) do Logger.error("Invalid directory: #{start_path}") {:error, "Directory does not exist: #{start_path}"} else initial_state = %State{ current_path: start_path, left_pane_path: start_path, right_pane_path: start_path, files: [], selected_index: 0, active_pane: :left, config: config, search_query: "", search_results: [], preview_content: nil, status_message: "", mode: :browse, clipboard: nil } case load_directory(initial_state) do {:ok, loaded_state} -> start_browser(loaded_state) {:error, reason} -> {:error, reason} end end end def start(opts, _) when is_list(opts) do path = Keyword.get(opts, :path, ".") start(path, opts) end # UI Implementation @impl Raxol.UI def render(assigns) do ~H"""
<%= render_header(assigns) %> <%= render_main_content(assigns) %> <%= render_status_bar(assigns) %> <%= render_help_overlay(assigns) %>
""" end defp render_header(assigns) do ~H"""
<%= @config.view_mode %> <%= if @config.dual_pane do %> <%= if @active_pane == :left, do: "◀ LEFT", else: "RIGHT ▶" %> <% end %>
""" end defp render_breadcrumb(assigns) do current_path = if assigns.config.dual_pane do if assigns.active_pane == :left, do: assigns.left_pane_path, else: assigns.right_pane_path else assigns.current_path end path_parts = Path.split(current_path) ~H""" """ end defp render_main_content(assigns) do if assigns.config.dual_pane do render_dual_pane(assigns) else render_single_pane(assigns) end end defp render_single_pane(assigns) do ~H"""
<%= render_file_list(assigns, assigns.files) %>
<%= if assigns.config.preview_enabled and assigns.preview_content do %>
<%= render_file_preview(assigns) %>
<% end %>
""" end defp render_dual_pane(assigns) do ~H"""
Left Pane
<%= render_file_list(assigns, get_pane_files(assigns, :left)) %>
Right Pane
<%= render_file_list(assigns, get_pane_files(assigns, :right)) %>
""" end defp render_file_list(assigns, files) do case assigns.config.view_mode do :list -> render_list_view(assigns, files) :tree -> render_tree_view(assigns, files) :grid -> render_grid_view(assigns, files) _ -> render_list_view(assigns, files) end end defp render_list_view(assigns, files) do ~H"""
<%= for {file, index} <- Enum.with_index(files) do %>
<%= get_file_icon(file) %> <%= file.name %> <%= format_file_size(file.size) %> <%= format_date(file.modified) %> <%= if file.symlink do %> → <%= file.symlink_target %> <% end %>
<% end %>
""" end defp render_tree_view(assigns, files) do ~H"""
<%= render_tree_node(assigns, files, 0) %>
""" end defp render_tree_node(assigns, files, depth) do ~H""" <%= for {file, index} <- Enum.with_index(files) do %>
<%= String.duplicate(" ", depth) %> <%= if file.type == :directory do %> <%= if file.expanded, do: "▼", else: "▶" %> <% end %> <%= get_file_icon(file) %> <%= file.name %>
<%= if file.type == :directory and file.expanded and file.children do %> <%= render_tree_node(assigns, file.children, depth + 1) %> <% end %> <% end %> """ end defp render_grid_view(assigns, files) do ~H"""
<%= for {file, index} <- Enum.with_index(files) do %>
<%= get_file_icon(file) %>
<%= file.name %>
<% end %>
""" end defp render_file_preview(assigns) do case assigns.preview_content do %{type: :text, content: content} -> render_text_preview(content) %{type: :image, path: path} -> render_image_preview(path) %{type: :binary, info: info} -> render_binary_preview(info) %{type: :error, message: message} -> render_error_preview(message) _ -> ~H"
No preview available
" end end defp render_text_preview(content) do # Limit preview lines lines = String.split(content, "\n") |> Enum.take(50) ~H"""
Text Preview
<%= for {line, line_num} <- Enum.with_index(lines, 1) do %>
<%= line_num %> <%= line %>
<% end %>
""" end defp render_image_preview(path) do ~H"""
Image Preview
Path: <%= path %>
Format: <%= Path.extname(path) %>
[Image: <%= Path.basename(path) %>]
""" end defp render_binary_preview(info) do ~H"""
Binary File
Size: <%= format_file_size(info.size) %>
Type: <%= info.mime_type || "Unknown" %>
Encoding: <%= info.encoding || "Binary" %>
<%= if info.hex_sample do %>
<%= info.hex_sample %>
<% else %> [Binary data - no preview available] <% end %>
""" end defp render_error_preview(message) do ~H"""
Error
<%= message %>
""" end defp render_status_bar(assigns) do file_count = length(assigns.files) dir_count = Enum.count(assigns.files, &(&1.type == :directory)) total_size = Enum.reduce(assigns.files, 0, &(&2 + (&1.size || 0))) ~H"""
<%= if assigns.status_message != "" do %> <%= assigns.status_message %> <% else %> <%= file_count %> items, <%= dir_count %> dirs, <%= format_file_size(total_size) %> <% end %>
<%= if assigns.search_query != "" do %> Search: "<%= assigns.search_query %>" (<%= length(assigns.search_results) %> results) <% end %>
<%= if assigns.config.show_hidden, do: "Hidden", else: "" %> <%= assigns.config.sort_by %> <%= if assigns.config.sort_direction == :desc, do: "↓", else: "↑" %>
""" end defp render_help_overlay(assigns) do return(unless(assigns.mode == :help)) ~H"""

File Browser Help

Navigation

File Operations

View

Search

""" end # Event Handlers defp handle_keydown(%{key: key, ctrl: ctrl} = event, state) do case {key, ctrl, state.mode} do {"q", false, :browse} -> {:stop, state} {"?", false, :browse} -> {:ok, %{state | mode: :help}} {_, _, :help} -> {:ok, %{state | mode: :browse}} {"j", false, :browse} -> move_selection(state, :down) {"k", false, :browse} -> move_selection(state, :up) {"h", false, :browse} -> navigate_to_parent(state) {"l", false, :browse} -> enter_directory_or_preview(state) {"Enter", false, :browse} -> open_selected_item(state) {"Space", false, :browse} -> preview_selected_item(state) {"/", false, :browse} -> start_search(state) {"r", false, :browse} -> refresh_directory(state) {"v", false, :browse} -> cycle_view_mode(state) {"Tab", false, :browse} when state.config.dual_pane -> switch_active_pane(state) _ -> {:ok, %{state | status_message: "Unknown command: #{key}"}} end end # Navigation and File Operations defp move_selection(state, direction) do current_index = state.selected_index file_count = length(state.files) new_index = case direction do :up -> max(0, current_index - 1) :down -> min(file_count - 1, current_index + 1) end new_state = %{state | selected_index: new_index} # Auto-preview if enabled if state.config.preview_enabled and new_index != current_index do preview_file_at_index(new_state, new_index) else {:ok, new_state} end end defp navigate_to_parent(state) do current_path = get_current_path(state) parent_path = Path.dirname(current_path) if parent_path != current_path do navigate_to_directory(state, parent_path) else {:ok, %{state | status_message: "Already at root directory"}} end end defp navigate_to_directory(state, new_path) do updated_state = if state.config.dual_pane do case state.active_pane do :left -> %{state | left_pane_path: new_path} :right -> %{state | right_pane_path: new_path} end else %{state | current_path: new_path} end load_directory(updated_state) end defp open_selected_item(state) do case get_selected_file(state) do nil -> {:ok, state} file -> case file.type do :directory -> full_path = Path.join(get_current_path(state), file.name) navigate_to_directory(state, full_path) :file -> open_file_with_default_app(file, state) end end end defp preview_selected_item(state) do case get_selected_file(state) do nil -> {:ok, state} file -> preview_file_at_index(state, state.selected_index) end end defp preview_file_at_index(state, index) do case Enum.at(state.files, index) do nil -> {:ok, %{state | preview_content: nil}} file -> full_path = Path.join(get_current_path(state), file.name) case FilePreview.generate_preview(full_path, state.config) do {:ok, preview_content} -> {:ok, %{state | preview_content: preview_content}} {:error, reason} -> error_preview = %{type: :error, message: reason} {:ok, %{state | preview_content: error_preview}} end end end defp load_directory(state) do current_path = get_current_path(state) case DirectoryScanner.scan_directory(current_path, state.config) do {:ok, files} -> sorted_files = sort_files(files, state.config) {:ok, %{ state | files: sorted_files, selected_index: 0, status_message: "", preview_content: nil }} {:error, reason} -> {:error, "Failed to load directory: #{reason}"} end end # Helper Functions defp start_browser(state) do case Raxol.UI.start_link(__MODULE__, state) do {:ok, pid} -> Logger.info("File Browser started") run_browser_loop(pid) {:error, reason} -> Logger.error("Failed to start browser: #{inspect(reason)}") {:error, reason} end end defp run_browser_loop(pid) do receive do {:stop} -> Raxol.UI.stop(pid) {:refresh} -> Raxol.UI.refresh(pid) run_browser_loop(pid) other -> Logger.debug("Browser loop received: #{inspect(other)}") run_browser_loop(pid) end end defp get_current_path(state) do if state.config.dual_pane do case state.active_pane do :left -> state.left_pane_path :right -> state.right_pane_path end else state.current_path end end defp get_selected_file(state) do Enum.at(state.files, state.selected_index) end defp get_pane_files(state, pane) do # In a full implementation, each pane would have its own file list state.files end defp sort_files(files, config) do sorted = case config.sort_by do :name -> Enum.sort_by(files, & &1.name) :size -> Enum.sort_by(files, &(&1.size || 0)) :type -> Enum.sort_by(files, & &1.type) :modified -> Enum.sort_by(files, & &1.modified) _ -> files end if config.sort_direction == :desc do Enum.reverse(sorted) else sorted end end defp file_item_class(index, state) do classes = ["file-item"] if index == state.selected_index do ["selected" | classes] else classes end |> Enum.join(" ") end defp pane_class(pane, state) do classes = ["file-pane", Atom.to_string(pane)] if state.active_pane == pane do ["active" | classes] else classes end |> Enum.join(" ") end defp get_file_icon(file) do case file.type do :directory -> "📁" :file -> case Path.extname(file.name) do ".txt" -> "📄" ".md" -> "📝" ".ex" -> "💎" ".exs" -> "💎" ".js" -> "📜" ".html" -> "🌐" ".css" -> "🎨" ".json" -> "📋" ".png" -> "🖼️" ".jpg" -> "🖼️" ".jpeg" -> "🖼️" ".gif" -> "🖼️" ".mp3" -> "🎵" ".mp4" -> "🎬" ".pdf" -> "📕" ".zip" -> "🗜️" ".tar" -> "🗜️" ".gz" -> "🗜️" _ -> "📄" end end end defp format_file_size(nil), do: "" defp format_file_size(size) when size < 1024, do: "#{size}B" defp format_file_size(size) when size < 1_048_576, do: "#{div(size, 1024)}KB" defp format_file_size(size) when size < 1_073_741_824, do: "#{div(size, 1_048_576)}MB" defp format_file_size(size), do: "#{Float.round(size / 1_073_741_824, 1)}GB" defp format_date(datetime) do case datetime do %DateTime{} -> Calendar.strftime(datetime, "%Y-%m-%d %H:%M") _ -> "" end end # Placeholder implementations for complex operations defp start_search(state) do {:ok, %{state | mode: :search, status_message: "Search mode not implemented yet"}} end defp refresh_directory(state) do load_directory(state) end defp cycle_view_mode(state) do new_mode = case state.config.view_mode do :list -> :tree :tree -> :grid :grid -> :list end new_config = %{state.config | view_mode: new_mode} {:ok, %{state | config: new_config, status_message: "View mode: #{new_mode}"}} end defp switch_active_pane(state) do new_pane = if state.active_pane == :left, do: :right, else: :left {:ok, %{state | active_pane: new_pane}} end defp open_file_with_default_app(file, state) do {:ok, %{state | status_message: "Opening #{file.name}..."}} end # Additional helper function stubs defp handle_file_click(_file, _index, state), do: {:ok, state} defp handle_file_double_click(_file, state), do: {:ok, state} defp handle_breadcrumb_click(_index, _parts, state), do: {:ok, state} defp tree_item_class(_index, _depth, _state), do: "tree-item" defp grid_item_class(_index, _state), do: "grid-item" defp enter_directory_or_preview(state), do: {:ok, state} end