Skip to content

Commit 52938fd

Browse files
authored
Enhancement: Allow opening LiveView/LiveComponent in editor (#925)
* add path to file and open editor button * change detecting editor logic * add error flash * change tooltip message * add docs * add link to docs
1 parent 85bc241 commit 52938fd

File tree

12 files changed

+358
-37
lines changed

12 files changed

+358
-37
lines changed

assets/app/icons/external-link.svg

Lines changed: 1 addition & 0 deletions
Loading

assets/app/tailwind.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ module.exports = {
8383
'survey-link': 'var(--survey-link)',
8484
'survey-link-hover': 'var(--survey-link-hover)',
8585
},
86-
screens: { xs: '380px', md_ct: '860px' },
86+
screens: { xs: '380px', md_ct: '860px', sm_bi: '1200px', sm_ct: '600px' },
8787
fontFamily: {
8888
sans: ['Inter', 'sans-serif'],
8989
code: [

docs/open_in_editor.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Open files in your editor
2+
3+
This feature allows you to open files and specific lines directly from the **LiveDebugger** in your preferred editor.
4+
5+
## How it works
6+
The debugger determines which editor to use by checking environment variables in the following order:
7+
1. **`ELIXIR_EDITOR`**: Elixir-specific editor (takes priority).
8+
2. **`TERM_PROGRAM`**: Automatically set by integrated terminals (e.g., in VS Code or Zed).
9+
3. **`EDITOR`**: Default system editor (used if ELIXIR_EDITOR is not set and integrated terminal is not detected).
10+
11+
12+
## Integrated Terminal Support
13+
If you are using an integrated terminal inside **VS Code** or **Zed**, the editor opens automatically via the `TERM_PROGRAM` variable.
14+
15+
## GUI Editors
16+
Add the following to your shell profile (`.zshrc`, `.bashrc`, etc.) to ensure files open in your preferred editor from any terminal session:
17+
18+
For Visual Studio Code
19+
export ELIXIR_EDITOR="code --goto"
20+
For Zed
21+
export ELIXIR_EDITOR="zed"
22+
23+
For complex setups use the `__FILE__` and `__LINE__` placeholders.
24+
25+
```bash
26+
export ELIXIR_EDITOR="my_editor +__LINE__ __FILE__"
27+
```
28+
29+
## Terminal Editors
30+
31+
Opening terminal editor directly is not supported because of the potential lock of the iex session.
32+
We recommend using **Tmux** to open the file in a new window or a split pane.
33+
34+
### Example configuration
35+
36+
For Tmux and Helix
37+
38+
export ELIXIR_EDITOR="tmux neww 'hx __FILE__:__LINE__'"

e2e/playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export default defineConfig({
5252
/* Run your local dev server before starting the tests */
5353
webServer: {
5454
command:
55-
'cd .. && MIX_ENV=test iex -e "Application.put_env(:live_debugger, :e2e?, true)" -S mix',
55+
'cd .. && env -u TERM_PROGRAM -u EDITOR -u ELIXIR_EDITOR MIX_ENV=test iex -e "Application.put_env(:live_debugger, :e2e?, true)" -S mix',
5656
url: 'http://localhost:4005',
5757
reuseExistingServer: !process.env.CI,
5858
stdout: 'pipe',

e2e/tests/node-inspector.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,8 @@ test('return button redirects to active live views dashboard', async ({
103103
dbgApp.getByRole('heading', { name: 'Active LiveViews' })
104104
).toBeVisible();
105105
});
106+
107+
test('Open in editor is disabled when envs are not set', async ({ dbgApp }) => {
108+
const openButton = dbgApp.getByRole('button', { name: 'Open in editor' });
109+
await expect(openButton).toBeDisabled();
110+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
defmodule LiveDebugger.App.Debugger.Utils.Editor do
2+
@moduledoc """
3+
Utilities for opening editors
4+
"""
5+
6+
require Logger
7+
8+
@term_to_cmd %{
9+
"vscode" => "code -g",
10+
"zed" => "zed"
11+
}
12+
13+
@spec detect_editor() :: String.t() | nil
14+
def detect_editor() do
15+
cond do
16+
elixir_editor = System.get_env("ELIXIR_EDITOR") ->
17+
elixir_editor
18+
19+
mapped_editor = Map.get(@term_to_cmd, System.get_env("TERM_PROGRAM")) ->
20+
mapped_editor
21+
22+
system_editor = System.get_env("EDITOR") ->
23+
system_editor
24+
25+
true ->
26+
nil
27+
end
28+
end
29+
30+
@spec get_editor_cmd(String.t(), String.t(), integer()) :: String.t()
31+
def get_editor_cmd(editor, file, line)
32+
when is_binary(file) and is_integer(line) and is_binary(editor) do
33+
if editor =~ "__FILE__" or editor =~ "__LINE__" do
34+
editor
35+
|> String.replace("__FILE__", inspect(file))
36+
|> String.replace("__LINE__", Integer.to_string(line))
37+
else
38+
"#{editor} #{inspect(file)}:#{line}"
39+
end
40+
end
41+
42+
@spec run_shell_cmd(String.t()) :: :ok | {:error, term()}
43+
def run_shell_cmd(command) do
44+
case System.shell(command, stderr_to_stdout: true) do
45+
{_output, 0} ->
46+
:ok
47+
48+
{output, status} ->
49+
msg = format_shell_error(command, output, status)
50+
Logger.error(msg)
51+
{:error, msg}
52+
end
53+
end
54+
55+
defp format_shell_error(command, output, 127) do
56+
command_name =
57+
output
58+
|> String.split(":")
59+
|> Enum.at(-2, command)
60+
|> String.trim()
61+
62+
"""
63+
Error when opening editor: Could not find the "#{command_name}" command.
64+
"""
65+
end
66+
67+
defp format_shell_error(_command, output, status) do
68+
"Error when opening editor: Command failed with status #{status}. Output: #{String.trim(output)}"
69+
end
70+
end

lib/live_debugger/app/debugger/web/live_components/node_basic_info.ex

Lines changed: 138 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,35 @@ defmodule LiveDebugger.App.Debugger.Web.LiveComponents.NodeBasicInfo do
55

66
use LiveDebugger.App.Web, :live_component
77

8+
import LiveDebugger.App.Web.Hooks.Flash, only: [push_flash: 3]
89
alias LiveDebugger.App.Debugger.Structs.TreeNode
910
alias LiveDebugger.App.Debugger.Queries.Node, as: NodeQueries
1011
alias LiveDebugger.App.Debugger.Web.LiveComponents.SendEventFullscreen
1112
alias LiveDebugger.App.Utils.Parsers
12-
1313
alias LiveDebugger.App.Debugger.Web.Components.Pages
14+
alias LiveDebugger.App.Debugger.Utils.Editor
15+
alias LiveDebugger.App.Web.Hooks.Flash.LinkFlashData
16+
17+
@editor_docs_url "https://hexdocs.pm/live_debugger/open_in_editor.html"
1418

1519
@impl true
20+
def update(%{:editor_error => editor_error}, socket) do
21+
socket
22+
|> push_flash(:error, %LinkFlashData{
23+
text: editor_error,
24+
url: @editor_docs_url,
25+
label: "See the docs"
26+
})
27+
|> ok()
28+
end
29+
1630
def update(assigns, socket) do
1731
socket
1832
|> assign(:id, assigns.id)
1933
|> assign(:node_id, assigns.node_id)
2034
|> assign(:lv_process, assigns.lv_process)
35+
|> assign(:elixir_editor, Editor.detect_editor())
36+
|> assign(:editor_docs_url, @editor_docs_url)
2137
|> assign_node_type()
2238
|> assign_async_node_module()
2339
|> ok()
@@ -43,17 +59,85 @@ defmodule LiveDebugger.App.Debugger.Web.LiveComponents.NodeBasicInfo do
4359
<p>Couldn't load basic information about the node.</p>
4460
</.alert>
4561
</:failed>
46-
<div class="flex flex-row gap-8 max-md_ct:flex-col max-md_ct:gap-2 md_ct:items-center p-3">
47-
<div class="min-w-0 flex flex-col gap-2 max-md_ct:border-b max-md_ct:border-default-border">
62+
<div class="flex flex-row gap-8 max-sm_bi:flex-col max-sm_bi:gap-4 sm_bi:items-center p-3">
63+
<div class="min-w-0 flex flex-col gap-2">
4864
<span class="font-medium">Module:</span>
4965
<div class="flex gap-2 min-w-0">
50-
<.tooltip id={@id <> "-current-node-module"} content={node_module} class="truncate">
51-
<%= node_module %>
66+
<.tooltip
67+
id={@id <> "-current-node-module"}
68+
content={node_module.module_name}
69+
class="truncate"
70+
>
71+
<%= node_module.module_name %>
72+
</.tooltip>
73+
<.copy_button id="copy-button-module-name" value={node_module.module_name} />
74+
</div>
75+
</div>
76+
77+
<div class="min-w-0 flex flex-col gap-2">
78+
<span class="font-medium">Path:</span>
79+
80+
<div class="flex flex-row gap-2">
81+
<.tooltip
82+
id={@id <> "-current-node-module-path"}
83+
content={node_module.module_path <> ":" <> Integer.to_string(node_module.line)}
84+
class="truncate"
85+
>
86+
<%= node_module.module_path <> ":" <> Integer.to_string(node_module.line) %>
5287
</.tooltip>
53-
<.copy_button id="copy-button-module-name" value={node_module} />
88+
<.copy_button id="copy-button-module-path" value={node_module.module_path} />
89+
</div>
90+
</div>
91+
92+
<div class="shrink-0 flex flex-col gap-2 max-sm_bi:border-b max-sm_bi:border-default-border pb-2">
93+
<span class="font-medium">Type:</span>
94+
<span><%= @node_type %></span>
95+
</div>
96+
97+
<div class="flex flex-row gap-2 max-sm_ct:flex-col sm_bi:ml-auto">
98+
<div class="flex flex-row gap-2">
99+
<.button
100+
class="shrink-0 sm_bi:ml-auto"
101+
variant="secondary"
102+
size="sm"
103+
id="send-event-button"
104+
disabled={not @lv_process.alive?}
105+
phx-click="open-send-event"
106+
phx-target={@myself}
107+
>
108+
<.icon name="icon-send" class="w-4 h-4" /> Send Event
109+
</.button>
110+
111+
<div class="flex flex-row items-center gap-2">
112+
<.button
113+
disabled={!@elixir_editor}
114+
class="shrink-0"
115+
variant="secondary"
116+
id="open-in-editor"
117+
size="sm"
118+
phx-click="open-in-editor"
119+
phx-target={@myself}
120+
phx-value-file={node_module.module_path}
121+
phx-value-line={node_module.line}
122+
>
123+
<.icon name="icon-external-link" class="w-4 h-4" /> Open in Editor
124+
</.button>
125+
126+
<.tooltip
127+
id={@id <> "-env-not-set"}
128+
content="Cannot open in editor? Click to see the documentation."
129+
>
130+
<span :if={!@elixir_editor} class="text-error-text">
131+
<.link href={@editor_docs_url} target="_blank">
132+
<.icon name="icon-info" class="w-4 h-4" />
133+
</.link>
134+
</span>
135+
</.tooltip>
136+
</div>
54137
</div>
138+
55139
<.button
56-
class="shrink-0 md_ct:ml-auto md_ct:hidden mb-3"
140+
class="shrink-0 sm_bi:ml-auto md_ct:hidden"
57141
variant="secondary"
58142
size="sm"
59143
id="show-components-tree-button"
@@ -62,22 +146,6 @@ defmodule LiveDebugger.App.Debugger.Web.LiveComponents.NodeBasicInfo do
62146
<.icon name="icon-component" class="w-4 h-4" /> Show Components Tree
63147
</.button>
64148
</div>
65-
<div class="shrink-0 flex flex-col gap-2">
66-
<span class="font-medium">Type:</span>
67-
<span><%= @node_type %></span>
68-
</div>
69-
70-
<.button
71-
class="shrink-0 md_ct:ml-auto"
72-
variant="secondary"
73-
size="sm"
74-
id="send-event-button"
75-
disabled={not @lv_process.alive?}
76-
phx-click="open-send-event"
77-
phx-target={@myself}
78-
>
79-
<.icon name="icon-send" class="w-4 h-4" /> Send Event
80-
</.button>
81149
</div>
82150
</.async_result>
83151
<.live_component
@@ -97,6 +165,29 @@ defmodule LiveDebugger.App.Debugger.Web.LiveComponents.NodeBasicInfo do
97165
|> noreply()
98166
end
99167

168+
def handle_event("open-in-editor", %{"file" => file, "line" => line}, socket) do
169+
cmd = Editor.get_editor_cmd(socket.assigns.elixir_editor, file, line |> String.to_integer())
170+
171+
# Some editors may block iex, so we spawn a new process
172+
component_id = socket.assigns.id
173+
component_pid = self()
174+
175+
spawn(fn ->
176+
case Editor.run_shell_cmd(cmd) do
177+
:ok ->
178+
:ok
179+
180+
{:error, reason} ->
181+
send_update(component_pid, __MODULE__,
182+
id: component_id,
183+
editor_error: reason
184+
)
185+
end
186+
end)
187+
188+
{:noreply, socket}
189+
end
190+
100191
defp assign_node_type(socket) do
101192
node_type =
102193
socket.assigns.node_id
@@ -116,12 +207,34 @@ defmodule LiveDebugger.App.Debugger.Web.LiveComponents.NodeBasicInfo do
116207
assign_async(socket, :node_module, fn ->
117208
case NodeQueries.get_module_from_id(node_id, pid) do
118209
{:ok, module} ->
119-
node_module = Parsers.module_to_string(module)
120-
{:ok, %{node_module: node_module}}
210+
line = get_module_line(module)
211+
212+
path = module.__info__(:compile) |> Keyword.get(:source) |> List.to_string()
213+
214+
module_name = Parsers.module_to_string(module)
215+
216+
{:ok,
217+
%{
218+
node_module: %{
219+
module_name: module_name,
220+
module_path: path,
221+
line: line
222+
}
223+
}}
121224

122225
:error ->
123226
{:error, "Failed to get node module"}
124227
end
125228
end)
126229
end
230+
231+
defp get_module_line(module) do
232+
case Code.fetch_docs(module) do
233+
{:docs_v1, _, _, _, _, %{source_annos: [{line, _column} | _]}, _} ->
234+
line
235+
236+
_ ->
237+
1
238+
end
239+
end
127240
end

0 commit comments

Comments
 (0)