Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements the Go to Definition keyboard shortcut #2741

Merged
merged 20 commits into from
Aug 16, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions assets/js/hooks/cell.js
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ const Cell = {

if (this.currentEditor()) {
this.currentEditor().focus();
this.scrollEditorCursorIntoViewIfNeeded();
this.scrollEditorCursorIntoViewIfNeeded("instant");
aleDsz marked this conversation as resolved.
Show resolved Hide resolved
}
} else if (this.insertMode && !insertMode) {
this.insertMode = insertMode;
Expand Down Expand Up @@ -362,12 +362,12 @@ const Cell = {
}
},

scrollEditorCursorIntoViewIfNeeded() {
scrollEditorCursorIntoViewIfNeeded(behavior = "smooth") {
const element = this.currentEditor().getElementAtCursor();

scrollIntoView(element, {
scrollMode: "if-needed",
behavior: "smooth",
behavior: behavior,
block: "center",
});
},
Expand Down
49 changes: 41 additions & 8 deletions assets/js/hooks/cell_editor/live_editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { settingsStore } from "../../lib/settings";
import Delta from "../../lib/delta";
import Markdown from "../../lib/markdown";
import { readOnlyHint } from "./live_editor/codemirror/read_only_hint";
import { wait } from "../../lib/utils";
import { isMacOS, wait } from "../../lib/utils";
import Emitter from "../../lib/emitter";
import CollabClient from "./live_editor/collab_client";
import { languages } from "./live_editor/codemirror/languages";
Expand Down Expand Up @@ -351,26 +351,39 @@ export default class LiveEditor {
}),
this.intellisense
? [
autocompletion({ override: [this.completionSource.bind(this)] }),
hoverTooltip(this.docsHoverTooltipSource.bind(this)),
signature(this.signatureSource.bind(this), {
activateOnTyping: settings.editor_auto_signature,
}),
formatter(this.formatterSource.bind(this)),
]
autocompletion({ override: [this.completionSource.bind(this)] }),
hoverTooltip(this.docsHoverTooltipSource.bind(this)),
signature(this.signatureSource.bind(this), {
activateOnTyping: settings.editor_auto_signature,
}),
formatter(this.formatterSource.bind(this)),
]
: [],
settings.editor_mode === "vim" ? [vim()] : [],
settings.editor_mode === "emacs" ? [emacs()] : [],
language ? language.support : [],
EditorView.domEventHandlers({
click: this.handleEditorClick.bind(this),
keydown: this.handleEditorKeydown.bind(this),
blur: this.handleEditorBlur.bind(this),
focus: this.handleEditorFocus.bind(this),
}),
EditorView.clickAddsSelectionRange.of((event) => event.altKey),
],
});
}

/** @private */
handleEditorClick(event) {
const cmd = isMacOS() ? event.metaKey : event.ctrlKey;

if (cmd) {
this.jumpToDefinition(this.view);
}

return false;
}

/** @private */
handleEditorKeydown(event) {
// We dispatch escape event, but only if it is not consumed by any
Expand Down Expand Up @@ -541,6 +554,26 @@ export default class LiveEditor {
.catch(() => null);
}

/** @private */
jumpToDefinition(view) {
const pos = view.state.selection.main.head;
const line = view.state.doc.lineAt(pos);
const lineLength = line.to - line.from;
const text = line.text;

const column = pos - line.from;
if (column < 1 || column > lineLength) return null;

return this.connection
.intellisenseRequest("definition", { line: text, column })
.then((response) => {
globalPubsub.broadcast("jump_to_editor", { line: response.line, file: response.file });

return true;
})
.catch(() => false);
}

/** @private */
signatureSource({ state, pos }) {
const textUntilCursor = this.getSignatureHint(state, pos);
Expand Down
15 changes: 15 additions & 0 deletions assets/js/hooks/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,20 @@ const Session = {
(event) => this.toggleCollapseAllSections(),
);

this.subscriptions = [
globalPubsub.subscribe("jump_to_editor", ({ line, file }) => {
const cellId = file.split("#cell:");
aleDsz marked this conversation as resolved.
Show resolved Hide resolved

this.setFocusedEl(cellId);
this.setInsertMode(true);

globalPubsub.broadcast(`cells:${cellId}`, {
type: "jump_to_line",
line,
});
}),
];

this.initializeDragAndDrop();

window.addEventListener(
Expand Down Expand Up @@ -270,6 +284,7 @@ const Session = {
leaveChannel();
}

this.subscriptions.forEach((subscription) => subscription.destroy());
this.store.destroy();
},

Expand Down
76 changes: 58 additions & 18 deletions lib/livebook/intellisense.ex
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ defmodule Livebook.Intellisense do
get_details(line, column, context, node)
end

def handle_request({:definition, line, column}, context, node) do
get_definitions(line, column, context, node)
end

def handle_request({:signature, hint}, context, node) do
get_signature_items(hint, context, node)
end
Expand Down Expand Up @@ -452,7 +456,7 @@ defmodule Livebook.Intellisense do
) do
join_with_divider([
code(inspect(module)),
format_definition_link(module, context),
format_definition_link(module, context, {:module, module}),
format_docs_link(module),
format_documentation(documentation, :all)
])
Expand Down Expand Up @@ -539,23 +543,9 @@ defmodule Livebook.Intellisense do
"""
end

defp format_definition_link(module, context, function_or_type \\ nil) do
if context.ebin_path do
path = Path.join(context.ebin_path, "#{module}.beam")

identifier =
if function_or_type,
do: function_or_type,
else: {:module, module}

with true <- File.exists?(path),
{:ok, line} <- Docs.locate_definition(path, identifier) do
file = module.module_info(:compile)[:source]
query_string = URI.encode_query(%{file: to_string(file), line: line})
"[Go to definition](#go-to-definition?#{query_string})"
else
_otherwise -> nil
end
defp format_definition_link(module, context, identifier) do
if query = get_definition_location(module, context, identifier) do
"[<> View definition](#go-to-definition?#{URI.encode_query(query)})"
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved
end
end

Expand Down Expand Up @@ -710,6 +700,56 @@ defmodule Livebook.Intellisense do
raise "unknown documentation format #{inspect(format)}"
end

@doc """
Returns the identifier definition located in `column` in `line`.
"""
@spec get_definitions(String.t(), pos_integer(), context(), node()) ::
Runtime.definition_response() | nil
def get_definitions(line, column, context, node) do
case IdentifierMatcher.locate_identifier(line, column, context, node) do
%{matches: []} ->
nil

%{matches: matches, range: range} ->
matches
|> Enum.sort_by(& &1[:arity], :asc)
|> Enum.flat_map(&List.wrap(get_definition_location(&1, context)))
|> case do
[%{file: file, line: line} | _] -> %{range: range, file: file, line: line}
_ -> nil
end
end
end

defp get_definition_location(%{kind: :module, module: module}, context) do
get_definition_location(module, context, {:module, module})
end

defp get_definition_location(
%{kind: :function, module: module, name: name, arity: arity},
context
) do
get_definition_location(module, context, {:function, name, arity})
end

defp get_definition_location(%{kind: :type, module: module, name: name, arity: arity}, context) do
get_definition_location(module, context, {:type, name, arity})
end

defp get_definition_location(module, context, identifier) do
if context.ebin_path do
path = Path.join(context.ebin_path, "#{module}.beam")

with true <- File.exists?(path),
{:ok, line} <- Docs.locate_definition(path, identifier) do
file = module.module_info(:compile)[:source]
%{file: to_string(file), line: line}
else
_otherwise -> nil
end
end
end

# Erlang HTML AST
# See https://erlang.org/doc/apps/erl_docgen/doc_storage.html#erlang-documentation-format

Expand Down
17 changes: 17 additions & 0 deletions lib/livebook/runtime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ defprotocol Livebook.Runtime do
@type intellisense_request ::
completion_request()
| details_request()
| definition_request()
| signature_request()
| format_request()

Expand All @@ -516,6 +517,7 @@ defprotocol Livebook.Runtime do
nil
| completion_response()
| details_response()
| definition_response()
| signature_response()
| format_response()

Expand Down Expand Up @@ -553,6 +555,21 @@ defprotocol Livebook.Runtime do
contents: list(String.t())
}

@typedoc """
Looks up more the definition about an identifier found in `column` in
`line`.
"""
@type definition_request :: {:definition, line :: String.t(), column :: pos_integer()}

@type definition_response :: %{
range: %{
from: non_neg_integer(),
to: non_neg_integer()
},
line: pos_integer(),
file: String.t()
}

@typedoc """
Looks up a list of function signatures matching the given hint.

Expand Down
4 changes: 4 additions & 0 deletions lib/livebook_web/live/session_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,10 @@ defmodule LivebookWeb.SessionLive do
column = Text.JS.js_column_to_elixir(column, line)
{:details, line, column}

%{"type" => "definition", "line" => line, "column" => column} ->
column = Text.JS.js_column_to_elixir(column, line)
{:definition, line, column}

%{"type" => "signature", "hint" => hint} ->
{:signature, hint}

Expand Down
Loading
Loading