From 86316e9460374ae073544a855f51caadcfdd09ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Thu, 1 Jul 2021 11:50:04 +0200 Subject: [PATCH] Add code formatting integration to Elixir cells (#416) --- assets/js/cell/live_editor.js | 53 +++++++++++++++++++ assets/js/cell/live_editor/monaco.js | 13 +++++ lib/livebook_web/live/session_live.ex | 13 +++++ .../live/session_live/shortcuts_component.ex | 37 ++++++------- 4 files changed, 96 insertions(+), 20 deletions(-) diff --git a/assets/js/cell/live_editor.js b/assets/js/cell/live_editor.js index a1f952631..237bcb895 100644 --- a/assets/js/cell/live_editor.js +++ b/assets/js/cell/live_editor.js @@ -23,6 +23,7 @@ class LiveEditor { if (type === "elixir") { this.__setupCompletion(); + this.__setupFormatting(); } const serverAdapter = new HookServerAdapter(hook, cellId); @@ -247,6 +248,58 @@ class LiveEditor { } ); } + + __setupFormatting() { + /** + * Similarly to completion, formatting is delegated to the function + * defined below, where we simply communicate with LV to get + * a formatted version of the current editor content. + */ + + this.editor.getModel().__getDocumentFormattingEdits = (model) => { + const content = model.getValue(); + + return new Promise((resolve, reject) => { + this.hook.pushEvent( + "format_code", + { code: content }, + ({ code: formatted }) => { + /** + * We use a single edit replacing the whole editor content, + * but the editor itself optimises this into a list of edits + * that produce minimal diff using the Myers string difference. + * + * References: + * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L324 + * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/common/services/editorSimpleWorker.ts#L489 + * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/base/common/diff/diff.ts#L227-L231 + * + * Eventually the editor will received the optimised list of edits, + * which we then convert to Delta and send to the server. + * Consequently, the Delta carries only the minimal formatting diff. + * + * Also, if edits are applied to the editor, either by typing + * or receiving remote changes, the formatting is cancelled. + * In other words the formatting changes are actually applied + * only if the editor stays intact. + * + * References: + * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L313 + * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/browser/core/editorState.ts#L137 + * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L326 + */ + + const replaceEdit = { + range: model.getFullModelRange(), + text: formatted, + }; + + resolve([replaceEdit]); + } + ); + }); + }; + } } function completionItemsToSuggestions(items) { diff --git a/assets/js/cell/live_editor/monaco.js b/assets/js/cell/live_editor/monaco.js index 59701b0d1..6616f0f39 100644 --- a/assets/js/cell/live_editor/monaco.js +++ b/assets/js/cell/live_editor/monaco.js @@ -40,6 +40,19 @@ monaco.languages.registerCompletionItemProvider("elixir", { }, }); +// Define custom code formatting provider. +// Formatting is cell agnostic, but we still delegate +// to a cell specific implementation to communicate with LV. +monaco.languages.registerDocumentFormattingEditProvider("elixir", { + provideDocumentFormattingEdits: function (model, options, token) { + if (model.__getDocumentFormattingEdits) { + return model.__getDocumentFormattingEdits(model); + } else { + return []; + } + }, +}); + export default monaco; /** diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index ca736ca76..7009955a9 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -612,6 +612,19 @@ defmodule LivebookWeb.SessionLive do {:noreply, socket} end + def handle_event("format_code", %{"code" => code}, socket) do + formatted = + try do + code + |> Code.format_string!() + |> IO.iodata_to_binary() + rescue + _ -> code + end + + {:reply, %{code: formatted}, socket} + end + defp create_session(socket, opts) do case SessionSupervisor.create_session(opts) do {:ok, id} -> diff --git a/lib/livebook_web/live/session_live/shortcuts_component.ex b/lib/livebook_web/live/session_live/shortcuts_component.ex index 7a7670a38..0f5a42abd 100644 --- a/lib/livebook_web/live/session_live/shortcuts_component.ex +++ b/lib/livebook_web/live/session_live/shortcuts_component.ex @@ -4,13 +4,24 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do @shortcuts %{ insert_mode: [ %{seq: ["esc"], desc: "Switch back to navigation mode"}, - %{seq: ["ctrl", "↵"], press_all: true, desc: "Evaluate cell and stay in insert mode"}, + %{ + seq: ["ctrl", "↵"], + seq_mac: ["⌘", "↵"], + press_all: true, + desc: "Evaluate cell and stay in insert mode" + }, %{seq: ["tab"], desc: "Autocomplete expression when applicable"}, %{ seq: ["ctrl", "␣"], press_all: true, - transform_for_mac: false, desc: "Show completion list, use twice for details" + }, + %{ + seq: ["ctrl", "shift", "i"], + seq_mac: ["⇧", "⌥", "f"], + seq_windows: ["shift", "alt", "f"], + press_all: true, + desc: "Format Elixir code" } ], navigation_mode: [ @@ -36,7 +47,7 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do %{seq: ["s", "b"], desc: "Show bin"} ], universal: [ - %{seq: ["ctrl", "s"], press_all: true, desc: "Save notebook"} + %{seq: ["ctrl", "s"], seq_mac: ["⌘", "s"], press_all: true, desc: "Save notebook"} ] } @@ -74,7 +85,7 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do

<%= @title %>

-
+
<%= render_shortcuts_section_table(@left, @platform) %>
@@ -107,18 +118,12 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do end defp render_shortcut_seq(shortcut, platform) do - seq = - if platform == :mac and Map.get(shortcut, :transform_for_mac, true) do - seq_for_mac(shortcut.seq) - else - shortcut.seq - end - + seq = shortcut[:"seq_#{platform}"] || shortcut.seq press_all = Map.get(shortcut, :press_all, false) joiner = if press_all do - remix_icon("add-line", class: "text-xl text-gray-600") + remix_icon("add-line", class: "text-lg text-gray-600") end elements = Enum.map_intersperse(seq, joiner, &content_tag("kbd", &1)) @@ -133,14 +138,6 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do """ end - defp seq_for_mac(seq) do - Enum.map(seq, fn - "ctrl" -> "⌘" - "alt" -> "⌥" - key -> key - end) - end - defp split_in_half(list) do half_idx = list |> length() |> Kernel.+(1) |> div(2) Enum.split(list, half_idx)