diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index 359a6a6149..75897699fe 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -34,7 +34,7 @@ jobs: fail-fast: false matrix: # We test quite a lot of versions because we do some OS and version specific things unfortunately - julia-version: ["1.6", "1.8", "1.10", "nightly"] # "~1.11.0-0"] + julia-version: ["1.6", "1.10", "~1.11.0-0", "nightly"] # "~1.12.0-0"] os: [ubuntu-latest, macOS-latest, windows-latest] steps: diff --git a/Project.toml b/Project.toml index c9aa8629ef..315bbcc5d9 100644 --- a/Project.toml +++ b/Project.toml @@ -2,7 +2,7 @@ name = "Pluto" uuid = "c3e4b0f8-55cb-11ea-2926-15256bba5781" license = "MIT" authors = ["Fons van der Plas "] -version = "0.19.39" +version = "0.19.40" [deps] Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" diff --git a/frontend/common/useEventListener.js b/frontend/common/useEventListener.js index 94b8911846..86fe78156f 100644 --- a/frontend/common/useEventListener.js +++ b/frontend/common/useEventListener.js @@ -1,7 +1,7 @@ import { useCallback, useEffect } from "../imports/Preact.js" export const useEventListener = ( - /** @type {Document | HTMLElement | Window | null} */ element, + /** @type {Document | HTMLElement | Window | EventSource | MediaQueryList | null} */ element, /** @type {string} */ event_name, /** @type {EventListenerOrEventListenerObject} */ handler, /** @type {any[] | undefined} */ deps diff --git a/frontend/components/CellInput.js b/frontend/components/CellInput.js index b48f265df6..39cd11b9ea 100644 --- a/frontend/components/CellInput.js +++ b/frontend/components/CellInput.js @@ -63,6 +63,7 @@ import { moveLineDown } from "../imports/CodemirrorPlutoSetup.js" export const ENABLE_CM_MIXED_PARSER = window.localStorage.getItem("ENABLE_CM_MIXED_PARSER") === "true" export const ENABLE_CM_SPELLCHECK = window.localStorage.getItem("ENABLE_CM_SPELLCHECK") === "true" +export const ENABLE_CM_AUTOCOMPLETE_ON_TYPE = window.localStorage.getItem("ENABLE_CM_AUTOCOMPLETE_ON_TYPE") === "true" if (ENABLE_CM_MIXED_PARSER) { console.log(`YOU ENABLED THE CODEMIRROR MIXED LANGUAGE PARSER @@ -85,6 +86,12 @@ window.PLUTO_TOGGLE_CM_SPELLCHECK = (val = !ENABLE_CM_SPELLCHECK) => { window.location.reload() } +// @ts-ignore +window.PLUTO_TOGGLE_CM_AUTOCOMPLETE_ON_TYPE = (val = !ENABLE_CM_AUTOCOMPLETE_ON_TYPE) => { + window.localStorage.setItem("ENABLE_CM_AUTOCOMPLETE_ON_TYPE", String(val)) + window.location.reload() +} + export const pluto_syntax_colors = HighlightStyle.define( [ /* The following three need a specific version of the julia parser, will add that later (still messing with it 😈) */ diff --git a/frontend/components/CellInput/pluto_autocomplete.js b/frontend/components/CellInput/pluto_autocomplete.js index 7b9f5f8ad3..509f9304f4 100644 --- a/frontend/components/CellInput/pluto_autocomplete.js +++ b/frontend/components/CellInput/pluto_autocomplete.js @@ -1,7 +1,5 @@ import _ from "../../imports/lodash.js" -import { utf8index_to_ut16index } from "../../common/UnicodeTools.js" - import { EditorState, EditorSelection, @@ -17,6 +15,8 @@ import { get_selected_doc_from_state } from "./LiveDocsFromCursor.js" import { cl } from "../../common/ClassTable.js" import { ScopeStateField } from "./scopestate_statefield.js" import { open_bottom_right_panel } from "../BottomRightPanel.js" +import { ENABLE_CM_AUTOCOMPLETE_ON_TYPE } from "../CellInput.js" +import { GlobalDefinitionsFacet } from "./go_to_definition_plugin.js" let { autocompletion, completionKeymap, completionStatus, acceptCompletion } = autocomplete @@ -82,11 +82,15 @@ const tab_completion_command = (cm) => { } let selection = cm.state.selection.main + if (!selection.empty) return false + let last_char = cm.state.sliceDoc(selection.from - 1, selection.from) + let last_line = cm.state.sliceDoc(cm.state.doc.lineAt(selection.from).from, selection.from) - if (!selection.empty) return false // Some exceptions for when to trigger tab autocomplete - if (/^(\t| |\n|\=|\)|)$/.test(last_char)) return false + if ("\t \n=".includes(last_char)) return false + // ?([1,2], 3) should trigger autocomplete + if (last_char === ")" && !last_line.includes("?")) return false cm.dispatch({ effects: TabCompletionEffect.of(10), @@ -161,11 +165,11 @@ let update_docs_from_autocomplete_selection = (on_update_doc_query) => { } /** Are we matching something like `\lambd...`? */ -let match_latex_complete = (ctx) => ctx.matchBefore(/\\[^\s"'.`]*/) +let match_latex_complete = (/** @type {autocomplete.CompletionContext} */ ctx) => ctx.matchBefore(/\\[^\s"'.`]*/) /** Are we matching something like `:writing_a_symbo...`? */ -let match_symbol_complete = (ctx) => ctx.matchBefore(/\.\:[^\s"'`()\[\].]*/) +let match_symbol_complete = (/** @type {autocomplete.CompletionContext} */ ctx) => ctx.matchBefore(/\.\:[^\s"'`()\[\].]*/) /** Are we matching exactly `~/`? */ -let match_expanduser_complete = (ctx) => ctx.matchBefore(/~\//) +let match_expanduser_complete = (/** @type {autocomplete.CompletionContext} */ ctx) => ctx.matchBefore(/~\//) /** Are we matching inside a string */ function match_string_complete(ctx) { const tree = syntaxTree(ctx.state) @@ -177,141 +181,147 @@ function match_string_complete(ctx) { } /** Use the completion results from the Julia server to create CM completion objects, but only for path completions (TODO: broken) and latex completions. */ -let julia_special_completions_to_cm = (/** @type {PlutoRequestAutocomplete} */ request_autocomplete) => async (ctx) => { - let to_complete = ctx.state.sliceDoc(0, ctx.pos) - - let found = await request_autocomplete({ text: to_complete }) - if (!found) return null - let { start, stop, results } = found - - let should_apply_unicode_completion = !match_string_complete(ctx) - - return { - from: start, - to: stop, - // This is an important one when you not only complete, but also replace something. - // @codemirror/autocomplete automatically filters out results otherwise >:( - filter: false, - options: results.map(([text, _, __, ___, ____, detail]) => { - return { - label: text, - apply: detail && should_apply_unicode_completion ? detail : text, - detail: detail ?? undefined, - } - }), - // TODO Do something docs_prefix ish when we also have the apply text +let julia_special_completions_to_cm = + (/** @type {PlutoRequestAutocomplete} */ request_autocomplete) => async (/** @type {autocomplete.CompletionContext} */ ctx) => { + let to_complete = ctx.state.sliceDoc(0, ctx.pos) + + let found = await request_autocomplete({ text: to_complete }) + if (!found) return null + let { start, stop, results } = found + + let should_apply_unicode_completion = !match_string_complete(ctx) + + return { + from: start, + to: stop, + // This is an important one when you not only complete, but also replace something. + // @codemirror/autocomplete automatically filters out results otherwise >:( + filter: false, + options: results.map(([text, _, __, ___, ____, detail]) => { + return { + label: text, + apply: detail && should_apply_unicode_completion ? detail : text, + detail: detail ?? undefined, + } + }), + // TODO Do something docs_prefix ish when we also have the apply text + } } -} let override_text_to_apply_in_field_expression = (text) => { return !/^[@a-zA-Z_][a-zA-Z0-9!_]*\"?$/.test(text) ? (text === ":" ? `:(${text})` : `:${text}`) : null } -/** - * @param {Map} definitions - * @param {Set} proposed - * @param {number} context_pos - */ -const generate_scopestate_completions = function* (definitions, proposed, context_pos) { - let i = 0 - for (let [name, { valid_from }] of definitions.entries()) { - if (!proposed.has(name) && valid_from < context_pos) { - yield { - label: name, - type: "c_Any", - boost: 99 - i, - } - i += 1 - } - } +const section_regular = { + name: "Suggestions", + header: () => document.createElement("div"), + rank: 0, } -/** Use the completion results from the Julia server to create CM completion objects. */ -const julia_code_completions_to_cm = (/** @type {PlutoRequestAutocomplete} */ request_autocomplete) => async (ctx) => { - let to_complete = ctx.state.sliceDoc(0, ctx.pos) - - // Another rough hack... If it detects a `.:`, we want to cut out the `:` so we get all results from julia, - // but then codemirror will put the `:` back in filtering - let is_symbol_completion = match_symbol_complete(ctx) - if (is_symbol_completion) { - to_complete = to_complete.slice(0, is_symbol_completion.from + 1) + to_complete.slice(is_symbol_completion.from + 2) - } +const section_operators = { + name: "Operators", + rank: 1, +} - let found = await request_autocomplete({ text: to_complete }) - if (!found) return null - let { start, stop, results } = found +/** Use the completion results from the Julia server to create CM completion objects. */ +const julia_code_completions_to_cm = + (/** @type {PlutoRequestAutocomplete} */ request_autocomplete) => async (/** @type {autocomplete.CompletionContext} */ ctx) => { + let to_complete = /** @type {String} */ (ctx.state.sliceDoc(0, ctx.pos)) + + // Another rough hack... If it detects a `.:`, we want to cut out the `:` so we get all results from julia, + // but then codemirror will put the `:` back in filtering + let is_symbol_completion = match_symbol_complete(ctx) + if (is_symbol_completion) { + to_complete = to_complete.slice(0, is_symbol_completion.from + 1) + to_complete.slice(is_symbol_completion.from + 2) + } - if (is_symbol_completion) { - // If this is a symbol completion thing, we need to add the `:` back in by moving the end a bit furher - stop = stop + 1 - } + // no path autocompletions + if (ctx.tokenBefore(["String"]) != null) return null - const definitions = ctx.state.field(ScopeStateField).definitions - const proposed = new Set() + const globals = ctx.state.facet(GlobalDefinitionsFacet) + const is_already_a_global = (text) => text != null && Object.keys(globals).includes(text) - let to_complete_onto = to_complete.slice(0, start) - let is_field_expression = to_complete_onto.slice(-1) === "." - return { - from: start, - to: stop, - - // This tells codemirror to not query this function again as long as the string - // we are completing has the same prefix as we complete now, and there is no weird characters (subjective) - // e.g. Base.ab, will create a regex like /^ab[^weird]*$/, so when now typing `s`, - // we'll get `Base.abs`, it finds the `abs` matching our span, and it will filter the existing results. - // If we backspace however, to `Math.a`, `a` does no longer match! So it will re-query this function. - // span: RegExp(`^${_.escapeRegExp(ctx.state.sliceDoc(start, stop))}[^\\s"'()\\[\\].{}]*`), - options: [ - ...results.map(([text, type_description, is_exported, is_from_notebook, completion_type], i) => { - // (quick) fix for identifiers that need to be escaped - // Ideally this is done with Meta.isoperator on the julia side - let text_to_apply = is_field_expression ? override_text_to_apply_in_field_expression(text) ?? text : text - - if (definitions.has(text)) proposed.add(text) + let found = await request_autocomplete({ text: to_complete }) + if (!found) return null + let { start, stop, results } = found - return { - label: text, - apply: text_to_apply, - type: - cl({ - c_notexported: !is_exported, - [`c_${type_description}`]: type_description != null, - [`completion_${completion_type}`]: completion_type != null, - c_from_notebook: is_from_notebook, - }) ?? undefined, - boost: 50 - i / results.length, - } - }), - // This is a small thing that I really want: - // You want to see what fancy symbols a module has? Pluto will show these at the very end of the list, - // for Base there is no way you're going to find them! With this you can type `.:` and see all the fancy symbols. - // TODO This whole block shouldn't use `override_text_to_apply_in_field_expression` but the same - // `Meta.isoperator` thing mentioned above - ...results - .filter(([text]) => is_field_expression && override_text_to_apply_in_field_expression(text) != null) - .map(([text, type_description, is_exported], i) => { - let text_to_apply = override_text_to_apply_in_field_expression(text) ?? "" - - return { - label: text_to_apply, - apply: text_to_apply, - type: (is_exported ? "" : "c_notexported ") + (type_description == null ? "" : "c_" + type_description), - boost: -99 - i / results.length, // Display below all normal results - // Non-standard - is_not_exported: !is_exported, - } - }), + if (is_symbol_completion) { + // If this is a symbol completion thing, we need to add the `:` back in by moving the end a bit furher + stop = stop + 1 + } - ...Array.from(generate_scopestate_completions(definitions, proposed, ctx.pos)), - ], + // const definitions = ctx.state.field(ScopeStateField).definitions + // console.debug({ definitions }) + // const proposed = new Set() + + let to_complete_onto = to_complete.slice(0, start) + let is_field_expression = to_complete_onto.slice(-1) === "." + return { + from: start, + to: stop, + + // see `is_wc_cat_id_start` in Julia's source for a complete list + validFor: /[\p{L}\p{Nl}\p{Sc}\d_]*$/u, + + // This tells codemirror to not query this function again as long as the string + // we are completing has the same prefix as we complete now, and there is no weird characters (subjective) + // e.g. Base.ab, will create a regex like /^ab[^weird]*$/, so when now typing `s`, + // we'll get `Base.abs`, it finds the `abs` matching our span, and it will filter the existing results. + // If we backspace however, to `Math.a`, `a` does no longer match! So it will re-query this function. + // span: RegExp(`^${_.escapeRegExp(ctx.state.sliceDoc(start, stop))}[^\\s"'()\\[\\].{}]*`), + options: [ + ...results + .filter(([text, _1, _2, is_from_notebook]) => !(is_from_notebook && is_already_a_global(text))) + .map(([text, value_type, is_exported, is_from_notebook, completion_type, _ignored], i) => { + // (quick) fix for identifiers that need to be escaped + // Ideally this is done with Meta.isoperator on the julia side + let text_to_apply = + completion_type === "method" ? to_complete : is_field_expression ? override_text_to_apply_in_field_expression(text) ?? text : text + + return { + label: text, + apply: text_to_apply, + type: + cl({ + c_notexported: !is_exported, + [`c_${value_type}`]: value_type != null, + [`completion_${completion_type}`]: completion_type != null, + c_from_notebook: is_from_notebook, + }) ?? undefined, + section: section_regular, + // boost: 50 - i / results.length, + } + }), + // This is a small thing that I really want: + // You want to see what fancy symbols a module has? Pluto will show these at the very end of the list, + // for Base there is no way you're going to find them! With this you can type `.:` and see all the fancy symbols. + // TODO This whole block shouldn't use `override_text_to_apply_in_field_expression` but the same + // `Meta.isoperator` thing mentioned above + ...results + .filter(([text]) => is_field_expression && override_text_to_apply_in_field_expression(text) != null) + .map(([text, value_type, is_exported], i) => { + let text_to_apply = override_text_to_apply_in_field_expression(text) ?? "" + + return { + label: text_to_apply, + apply: text_to_apply, + type: (is_exported ? "" : "c_notexported ") + (value_type == null ? "" : "c_" + value_type), + // boost: -99 - i / results.length, // Display below all normal results + section: section_operators, + // Non-standard + is_not_exported: !is_exported, + } + }), + ], + } } -} const pluto_completion_fetcher = (request_autocomplete) => { const unicode_completions = julia_special_completions_to_cm(request_autocomplete) const code_completions = julia_code_completions_to_cm(request_autocomplete) - return (ctx) => { + return (/** @type {autocomplete.CompletionContext} */ ctx) => { + if (ctx.tokenBefore(["Number"]) != null) return null let unicode_match = match_latex_complete(ctx) || match_expanduser_complete(ctx) if (unicode_match === null) { return code_completions(ctx) @@ -321,10 +331,13 @@ const pluto_completion_fetcher = (request_autocomplete) => { } } -const complete_anyword = async (ctx) => { +const complete_anyword = async (/** @type {autocomplete.CompletionContext} */ ctx) => { const results_from_cm = await autocomplete.completeAnyWord(ctx) if (results_from_cm === null) return null + const last_token = ctx.tokenBefore(["Identifier", "Number"]) + if (last_token == null || last_token.type?.name === "Number") return null + return { from: results_from_cm.from, options: results_from_cm.options.map(({ label }, i) => ({ @@ -332,12 +345,34 @@ const complete_anyword = async (ctx) => { label, apply: label, type: undefined, - boost: 0 - i, + section: section_regular, + // boost: 0 - i, })), } } -const local_variables_completion = (ctx) => { +const from_notebook_type = "c_from_notebook completion_module c_Any" + +const global_variables_completion = async (/** @type {autocomplete.CompletionContext} */ ctx) => { + const globals = ctx.state.facet(GlobalDefinitionsFacet) + + // see `is_wc_cat_id_start` in Julia's source for a complete list + const there_is_a_dot_before = ctx.matchBefore(/\.[\p{L}\p{Nl}\p{Sc}\d_]*$/u) + if (there_is_a_dot_before) return null + + return await autocomplete.completeFromList( + Object.keys(globals).map((label) => { + return { + label, + apply: label, + type: from_notebook_type, + section: section_regular, + } + }) + )(ctx) +} + +const local_variables_completion = (/** @type {autocomplete.CompletionContext} */ ctx) => { let scopestate = ctx.state.field(ScopeStateField) let unicode = ctx.tokenBefore(["Identifier"]) @@ -400,11 +435,10 @@ export let pluto_autocomplete = ({ request_autocomplete, on_update_doc_query }) return [ tabCompletionState, autocompletion({ - activateOnTyping: false, + activateOnTyping: ENABLE_CM_AUTOCOMPLETE_ON_TYPE, override: [ + global_variables_completion, pluto_completion_fetcher(memoize_last_request_autocomplete), - // julia_special_completions_to_cm(memoize_last_request_autocomplete), - // julia_code_completions_to_cm(memoize_last_request_autocomplete), complete_anyword, // TODO: Disabled because of performance problems, see https://github.com/fonsp/Pluto.jl/pull/1925. Remove `complete_anyword` once fixed. See https://github.com/fonsp/Pluto.jl/pull/2013 // local_variables_completion, diff --git a/frontend/components/CellInput/scopestate_statefield.js b/frontend/components/CellInput/scopestate_statefield.js index 0a22b182ce..2478d5ba65 100644 --- a/frontend/components/CellInput/scopestate_statefield.js +++ b/frontend/components/CellInput/scopestate_statefield.js @@ -563,13 +563,13 @@ export let explore_variable_usage = ( // Don't ask me why, but currently `do (x, y)` is parsed as `DoClauseArguments(ArgumentList(x, y))` // while an actual argumentslist, `do x, y` is parsed as `DoClauseArguments(BareTupleExpression(x, y))` let do_args_actually = do_args.firstChild - if (do_args_actually.name === "Identifier") { + if (do_args_actually?.name === "Identifier") { inner_scope = scopestate_add_definition(inner_scope, doc, do_args_actually) - } else if (do_args_actually.name === "ArgumentList") { + } else if (do_args_actually?.name === "ArgumentList") { for (let child of child_nodes(do_args_actually)) { inner_scope = explorer_function_definition_argument(child, doc, inner_scope) } - } else if (do_args_actually.name === "BareTupleExpression") { + } else if (do_args_actually?.name === "BareTupleExpression") { for (let child of child_nodes(do_args_actually)) { inner_scope = explorer_function_definition_argument(child, doc, inner_scope) } diff --git a/frontend/components/CellOutput.js b/frontend/components/CellOutput.js index b2a6244b88..77af503d88 100644 --- a/frontend/components/CellOutput.js +++ b/frontend/components/CellOutput.js @@ -410,6 +410,11 @@ const execute_scripttags = async ({ root_node, script_nodes, previous_results_ma // @ts-ignore getPublishedObject: (id) => cell.getPublishedObject(id), + _internal_getJSLinkResponse: (cell_id, link_id) => (input) => + pluto_actions.request_js_link_response(cell_id, link_id, input).then(([success, result]) => { + if (success) return result + throw result + }), getBoundElementValueLikePluto: get_input_value, setBoundElementValueLikePluto: set_input_value, getBoundElementEventNameLikePluto: eventof, diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index a74fa99243..d5ba9bdaa7 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -662,6 +662,19 @@ export class Editor extends Component { false ) }, + request_js_link_response: (cell_id, link_id, input) => { + return this.client + .send( + "request_js_link_response", + { + cell_id, + link_id, + input, + }, + { notebook_id: this.state.notebook.notebook_id } + ) + .then((r) => r.message) + }, /** This actions avoids pushing selected cells all the way down, which is too heavy to handle! */ get_selected_cells: (cell_id, /** @type {boolean} */ allow_other_selected_cells) => allow_other_selected_cells ? this.state.selected_cells : [cell_id], diff --git a/frontend/components/ExportBanner.js b/frontend/components/ExportBanner.js index a013995680..36e891c5df 100644 --- a/frontend/components/ExportBanner.js +++ b/frontend/components/ExportBanner.js @@ -64,10 +64,11 @@ export const ExportBanner = ({ notebook_id, print_title, open, onClose, notebook useEventListener( window, "beforeprint", - () => { - console.log("beforeprint") - print_old_title_ref.current = document.title - document.title = print_title.replace(/\.jl$/, "").replace(/\.plutojl$/, "") + (e) => { + if (!e.detail?.fake) { + print_old_title_ref.current = document.title + document.title = print_title.replace(/\.jl$/, "").replace(/\.plutojl$/, "") + } }, [print_title] ) @@ -79,6 +80,15 @@ export const ExportBanner = ({ notebook_id, print_title, open, onClose, notebook }, [print_title] ) + // https://github.com/codemirror/dev/issues/1354 + useEventListener( + window.matchMedia("print"), + "change", + () => { + // window.dispatchEvent(new CustomEvent("beforeprint", { detail: { fake: true } })) + }, + [] + ) const element_ref = useRef(/** @type {HTMLDialogElement?} */ (null)) diff --git a/frontend/editor.css b/frontend/editor.css index 5a049ba86b..326a77bcda 100644 --- a/frontend/editor.css +++ b/frontend/editor.css @@ -540,7 +540,7 @@ dialog#export { height: 130px; background: var(--export-bg-color); color: var(--export-color); - transform: translateY(-100%); + transform: translateY(calc(-100% - 1px)); /* overlay: none !important; */ overflow: visible; margin: 0; @@ -1744,11 +1744,13 @@ pluto-input > .input_context_menu ul { pluto-input { position: relative; + display: block; } pluto-input > div.input_context_menu { left: 100%; top: -8px; position: absolute; + z-index: 1400; } @media screen and (min-width: 921px) { pluto-input > div.input_context_menu { @@ -1756,9 +1758,6 @@ pluto-input > div.input_context_menu { } } @media screen and (max-width: 920px) { - pluto-input > div.input_context_menu { - z-index: 1400; - } pluto-input > div.input_context_menu { right: 0px; left: unset; @@ -3007,7 +3006,6 @@ pluto-log-dot-positioner { --accent-color: var(--pluto-logs-info-accent-color); --icon-image: unset; background: var(--bg-color); - color: var(--accent-color); margin: 2px; border-radius: 6px; /* border: 2px solid var(--accent-color); */ @@ -3017,8 +3015,8 @@ pluto-log-dot-positioner { background-size: 200% 100%; } -pluto-log-dot-positioner:last-child { - /* border-bottom: none; */ +pluto-log-dot > pre { + color: var(--accent-color); } pluto-log-truncated { @@ -3471,6 +3469,10 @@ pluto-cell.errored .cm-editor .cm-lineNumbers .cm-gutterElement::after { color: var(--cm-string-color); } +.cm-completionIcon-completion_keyword::before { + color: var(--cm-keyword-color); +} + .cm-completionIcon-c_Any::before, pluto-output > assignee, pluto-popup code.auto_disabled_variable { diff --git a/frontend/treeview.css b/frontend/treeview.css index 2c7663c79b..a668c4f9de 100644 --- a/frontend/treeview.css +++ b/frontend/treeview.css @@ -341,6 +341,7 @@ jlerror li .frame-line-preview pre:not(.asdfdsaf) { border-radius: var(--br); overflow: hidden; position: relative; + display: block; } jlerror li:not(.from_this_cell) .frame-line-preview pre::after { @@ -353,6 +354,9 @@ jlerror li:not(.from_this_cell) .frame-line-preview pre::after { opacity: 0.6; } +jlerror li .frame-line-preview pre > code { + padding: 0; +} jlerror li .frame-line-preview pre > code:not(:only-child).frame-line { background: var(--cm-highlighted); } diff --git a/src/evaluation/Run.jl b/src/evaluation/Run.jl index 7a8e4c4bd2..07f95c7666 100644 --- a/src/evaluation/Run.jl +++ b/src/evaluation/Run.jl @@ -141,8 +141,9 @@ function run_reactive_core!( new_errable = keys(new_order.errable) to_delete_vars = union!(to_delete_vars, defined_variables(new_topology, new_errable)...) to_delete_funcs = union!(to_delete_funcs, defined_functions(new_topology, new_errable)...) - + cells_to_macro_invalidate = Set{UUID}(c.cell_id for c in cells_with_deleted_macros(old_topology, new_topology)) + cells_to_js_link_invalidate = Set{UUID}(c.cell_id for c in union!(Set{Cell}(), to_run, new_errable, indirectly_deactivated)) module_imports_to_move = reduce(all_cells(new_topology); init=Set{Expr}()) do module_imports_to_move, c c ∈ to_run && return module_imports_to_move @@ -156,7 +157,7 @@ function run_reactive_core!( if will_run_code(notebook) to_delete_funcs_simple = Set{Tuple{UUID,Tuple{Vararg{Symbol}}}}((id, name.parts) for (id,name) in to_delete_funcs) - deletion_hook((session, notebook), old_workspace_name, nothing, to_delete_vars, to_delete_funcs_simple, module_imports_to_move, cells_to_macro_invalidate; to_run) # `deletion_hook` defaults to `WorkspaceManager.move_vars` + deletion_hook((session, notebook), old_workspace_name, nothing, to_delete_vars, to_delete_funcs_simple, module_imports_to_move, cells_to_macro_invalidate, cells_to_js_link_invalidate; to_run) # `deletion_hook` defaults to `WorkspaceManager.move_vars` end foreach(v -> delete!(notebook.bonds, v), to_delete_vars) diff --git a/src/evaluation/RunBonds.jl b/src/evaluation/RunBonds.jl index 2ccc82c190..7ec4e191b6 100644 --- a/src/evaluation/RunBonds.jl +++ b/src/evaluation/RunBonds.jl @@ -41,7 +41,7 @@ function set_bond_values_reactive(; bond_value_pairs = zip(syms_to_set, new_values) syms_to_set_set = Set{Symbol}(syms_to_set) - function custom_deletion_hook((session, notebook)::Tuple{ServerSession,Notebook}, old_workspace_name, new_workspace_name, to_delete_vars::Set{Symbol}, methods_to_delete, module_imports_to_move, cells_to_macro_invalidate; to_run) + function custom_deletion_hook((session, notebook)::Tuple{ServerSession,Notebook}, old_workspace_name, new_workspace_name, to_delete_vars::Set{Symbol}, methods_to_delete, module_imports_to_move, cells_to_macro_invalidate, cells_to_js_link_invalidate; to_run) to_delete_vars = union(to_delete_vars, syms_to_set_set) # also delete the bound symbols WorkspaceManager.move_vars( (session, notebook), @@ -51,6 +51,7 @@ function set_bond_values_reactive(; methods_to_delete, module_imports_to_move, cells_to_macro_invalidate, + cells_to_js_link_invalidate, syms_to_set_set, ) set_bond_value_pairs!(session, notebook, zip(syms_to_set, new_values)) diff --git a/src/evaluation/WorkspaceManager.jl b/src/evaluation/WorkspaceManager.jl index f77bbf0c83..05e6124549 100644 --- a/src/evaluation/WorkspaceManager.jl +++ b/src/evaluation/WorkspaceManager.jl @@ -555,6 +555,7 @@ function move_vars( methods_to_delete::Set{Tuple{UUID,Tuple{Vararg{Symbol}}}}, module_imports_to_move::Set{Expr}, cells_to_macro_invalidate::Set{UUID}, + cells_to_js_link_invalidate::Set{UUID}, keep_registered::Set{Symbol}=Set{Symbol}(); kwargs... ) @@ -570,6 +571,7 @@ function move_vars( $methods_to_delete, $module_imports_to_move, $cells_to_macro_invalidate, + $cells_to_js_link_invalidate, $keep_registered, ) end) @@ -580,16 +582,18 @@ function move_vars( to_delete::Set{Symbol}, methods_to_delete::Set{Tuple{UUID,Tuple{Vararg{Symbol}}}}, module_imports_to_move::Set{Expr}, - cells_to_macro_invalidate::Set{UUID}; + cells_to_macro_invalidate::Set{UUID}, + cells_to_js_link_invalidate::Set{UUID}; kwargs... ) move_vars( session_notebook, bump_workspace_module(session_notebook)..., - to_delete, + to_delete, methods_to_delete, module_imports_to_move, - cells_to_macro_invalidate; + cells_to_macro_invalidate, + cells_to_js_link_invalidate; kwargs... ) end diff --git a/src/notebook/Notebook.jl b/src/notebook/Notebook.jl index 0a34c59857..858081d9c3 100644 --- a/src/notebook/Notebook.jl +++ b/src/notebook/Notebook.jl @@ -44,8 +44,8 @@ Base.@kwdef mutable struct Notebook # per notebook compiler options # nothing means to use global session compiler options compiler_options::Union{Nothing,Configuration.CompilerOptions}=nothing - # nbpkg_ctx::Union{Nothing,PkgContext}=nothing - nbpkg_ctx::Union{Nothing,PkgContext}=PkgCompat.create_empty_ctx() + nbpkg_ctx::Union{Nothing,PkgContext}=nothing + # nbpkg_ctx::Union{Nothing,PkgContext}=PkgCompat.create_empty_ctx() nbpkg_ctx_instantiated::Bool=false nbpkg_restart_recommended_msg::Union{Nothing,String}=nothing nbpkg_restart_required_msg::Union{Nothing,String}=nothing diff --git a/src/packages/PkgCompat.jl b/src/packages/PkgCompat.jl index f0e857fc00..c84a9767ec 100644 --- a/src/packages/PkgCompat.jl +++ b/src/packages/PkgCompat.jl @@ -2,11 +2,21 @@ module PkgCompat export package_versions, package_completions +import REPL import Pkg import Pkg.Types: VersionRange import RegistryInstances import ..Pluto + + + +@static if isdefined(Pkg,:REPLMode) && isdefined(Pkg.REPLMode,:complete_remote_package) + const REPLMode=Pkg.REPLMode +else + const REPLMode = Base.get_extension(Pkg, :REPLExt) +end + # Should be in Base flatmap(args...) = vcat(map(args...)...) @@ -171,7 +181,7 @@ _get_registries() = RegistryInstances.reachable_registries() # (✅ "Public" API using RegistryInstances) "The cached output value of `_get_registries`." -const _parsed_registries = Ref(_get_registries()) +const _parsed_registries = Ref(RegistryInstances.RegistryInstance[]) # (✅ "Public" API using RegistryInstances) "Re-parse the installed registries from disk." @@ -179,6 +189,7 @@ function refresh_registry_cache() _parsed_registries[] = _get_registries() end + # ⚠️✅ Internal API with fallback const _updated_registries_compat = @static if isdefined(Pkg, :UPDATED_REGISTRY_THIS_SESSION) && Pkg.UPDATED_REGISTRY_THIS_SESSION isa Ref{Bool} Pkg.UPDATED_REGISTRY_THIS_SESSION @@ -264,7 +275,13 @@ end # ⚠️ Internal API with fallback is_stdlib(package_name::AbstractString) = package_name ∈ _stdlibs() -global_ctx = PkgContext() + + +# Initial fill of registry cache +function __init__() + refresh_registry_cache() + global global_ctx=PkgContext() +end ### # Package names @@ -282,10 +299,10 @@ end function _registered_package_completions(partial_name::AbstractString)::Vector{String} # compat try - @static if hasmethod(Pkg.REPLMode.complete_remote_package, (String,)) - Pkg.REPLMode.complete_remote_package(partial_name) + @static if hasmethod(REPLMode.complete_remote_package, (String,)) + REPLMode.complete_remote_package(partial_name) else - Pkg.REPLMode.complete_remote_package(partial_name, 1, length(partial_name))[1] + REPLMode.complete_remote_package(partial_name, 1, length(partial_name))[1] end catch e @warn "Pkg compat: failed to autocomplete packages" exception=(e,catch_backtrace()) diff --git a/src/precompile.jl b/src/precompile.jl index d7bbb8bd6b..d9373a20cd 100644 --- a/src/precompile.jl +++ b/src/precompile.jl @@ -36,13 +36,13 @@ PrecompileTools.@compile_workload begin Pluto.topological_order(topology, topology.cell_order) end - let - io = IOBuffer() - # Notebook file format. - Pluto.save_notebook(io, nb) - seekstart(io) - Pluto.load_notebook_nobackup(io, "whatever.jl") - end + # let + # io = IOBuffer() + # # Notebook file format. + # Pluto.save_notebook(io, nb) + # seekstart(io) + # Pluto.load_notebook_nobackup(io, "whatever.jl") + # end let state1 = Pluto.notebook_to_js(nb) diff --git a/src/runner/PlutoRunner/src/PlutoRunner.jl b/src/runner/PlutoRunner/src/PlutoRunner.jl index 1448f8174c..132df91d6e 100644 --- a/src/runner/PlutoRunner/src/PlutoRunner.jl +++ b/src/runner/PlutoRunner/src/PlutoRunner.jl @@ -26,7 +26,7 @@ import InteractiveUtils using Markdown import Markdown: html, htmlinline, LaTeX, withtag, htmlesc import Base64 -import FuzzyCompletions: Completion, BslashCompletion, ModuleCompletion, PropertyCompletion, FieldCompletion, PathCompletion, DictCompletion, completions, completion_text, score +import FuzzyCompletions: FuzzyCompletions, Completion, BslashCompletion, ModuleCompletion, PropertyCompletion, FieldCompletion, PathCompletion, DictCompletion, completions, completion_text, score import Base: show, istextmime import UUIDs: UUID, uuid4 import Dates: DateTime @@ -283,14 +283,9 @@ function try_macroexpand(mod::Module, notebook_id::UUID, cell_id::UUID, expr; ca Expr(:block, expr) end - logger = get!(() -> PlutoCellLogger(notebook_id, cell_id), pluto_cell_loggers, cell_id) - if logger.workspace_count < moduleworkspace_count[] - logger = pluto_cell_loggers[cell_id] = PlutoCellLogger(notebook_id, cell_id) - end + capture_logger = CaptureLogger(nothing, get_cell_logger(notebook_id, cell_id), Dict[]) - capture_logger = CaptureLogger(nothing, logger, Dict[]) - - expanded_expr, elapsed_ns = with_logger_and_io_to_logs(capture_logger; capture_stdout, stdio_loglevel=stdout_log_level) do + expanded_expr, elapsed_ns = with_logger_and_io_to_logs(capture_logger; capture_stdout) do elapsed_ns = time_ns() expanded_expr = macroexpand(mod, expr_not_toplevel)::Expr elapsed_ns = time_ns() - elapsed_ns @@ -531,10 +526,7 @@ function run_expression( old_currently_running_cell_id = currently_running_cell_id[] currently_running_cell_id[] = cell_id - logger = get!(() -> PlutoCellLogger(notebook_id, cell_id), pluto_cell_loggers, cell_id) - if logger.workspace_count < moduleworkspace_count[] - logger = pluto_cell_loggers[cell_id] = PlutoCellLogger(notebook_id, cell_id) - end + logger = get_cell_logger(notebook_id, cell_id) # reset published objects cell_published_objects[cell_id] = Dict{String,Any}() @@ -542,6 +534,9 @@ function run_expression( # reset registered bonds cell_registered_bond_names[cell_id] = Set{Symbol}() + # reset JS links + unregister_js_link(cell_id) + # If the cell contains macro calls, we want those macro calls to preserve their identity, # so we macroexpand this earlier (during expression explorer stuff), and then we find it here. # NOTE Turns out sometimes there is no macroexpanded version even though the expression contains macro calls... @@ -593,7 +588,7 @@ function run_expression( throw("Expression still contains macro calls!!") end - result, runtime = with_logger_and_io_to_logs(logger; capture_stdout, stdio_loglevel=stdout_log_level) do # about 200ns + 3ms overhead + result, runtime = with_logger_and_io_to_logs(logger; capture_stdout) do # about 200ns + 3ms overhead if function_wrapped_info === nothing toplevel_expr = Expr(:toplevel, expr) wrapped = timed_expr(toplevel_expr) @@ -691,6 +686,7 @@ function move_vars( methods_to_delete::Set{Tuple{UUID,Tuple{Vararg{Symbol}}}}, module_imports_to_move::Set{Expr}, cells_to_macro_invalidate::Set{UUID}, + cells_to_js_link_invalidate::Set{UUID}, keep_registered::Set{Symbol}, ) old_workspace = getfield(Main, old_workspace_name) @@ -701,7 +697,8 @@ function move_vars( for cell_id in cells_to_macro_invalidate delete!(cell_expanded_exprs, cell_id) end - + foreach(unregister_js_link, cells_to_js_link_invalidate) + # TODO: delete Core.eval(new_workspace, :(import ..($(old_workspace_name)))) @@ -803,35 +800,40 @@ function delete_toplevel_methods(f::Function, cell_id::UUID)::Bool methods_table = typeof(f).name.mt deleted_sigs = Set{Type}() Base.visit(methods_table) do method # iterates through all methods of `f`, including overridden ones - if isfromcell(method, cell_id) && getfield(method, deleted_world) == alive_world_val + if isfromcell(method, cell_id) && method.deleted_world == alive_world_val Base.delete_method(method) delete_method_doc(method) push!(deleted_sigs, method.sig) end end - # if `f` is an extension to an external function, and we defined a method that overrides a method, for example, - # we define `Base.isodd(n::Integer) = rand(Bool)`, which overrides the existing method `Base.isodd(n::Integer)` - # calling `Base.delete_method` on this method won't bring back the old method, because our new method still exists in the method table, and it has a world age which is newer than the original. (our method has a deleted_world value set, which disables it) - # - # To solve this, we iterate again, and _re-enable any methods that were hidden in this way_, by adding them again to the method table with an even newer `primary_world`. - if !isempty(deleted_sigs) - to_insert = Method[] - Base.visit(methods_table) do method - if !isfromcell(method, cell_id) && method.sig ∈ deleted_sigs - push!(to_insert, method) + + if VERSION < v"1.12.0-0" + # not necessary in Julia after https://github.com/JuliaLang/julia/pull/53415 💛 + + # if `f` is an extension to an external function, and we defined a method that overrides a method, for example, + # we define `Base.isodd(n::Integer) = rand(Bool)`, which overrides the existing method `Base.isodd(n::Integer)` + # calling `Base.delete_method` on this method won't bring back the old method, because our new method still exists in the method table, and it has a world age which is newer than the original. (our method has a deleted_world value set, which disables it) + # + # To solve this, we iterate again, and _re-enable any methods that were hidden in this way_, by adding them again to the method table with an even newer `primary_world`. + if !isempty(deleted_sigs) + to_insert = Method[] + Base.visit(methods_table) do method + if !isfromcell(method, cell_id) && method.sig ∈ deleted_sigs + push!(to_insert, method) + end end - end - # separate loop to avoid visiting the recently added method - for method in Iterators.reverse(to_insert) - if VERSION >= v"1.11.0-0" - @atomic method.primary_world = one(typeof(alive_world_val)) # `1` will tell Julia to increment the world counter and set it as this function's world - @atomic method.deleted_world = alive_world_val # set the `deleted_world` property back to the 'alive' value (for Julia v1.6 and up) - else - method.primary_world = one(typeof(alive_world_val)) - method.deleted_world = alive_world_val + # separate loop to avoid visiting the recently added method + for method in Iterators.reverse(to_insert) + if VERSION >= v"1.11.0-0" + @atomic method.primary_world = one(typeof(alive_world_val)) # `1` will tell Julia to increment the world counter and set it as this function's world + @atomic method.deleted_world = alive_world_val # set the `deleted_world` property back to the 'alive' value (for Julia v1.6 and up) + else + method.primary_world = one(typeof(alive_world_val)) + method.deleted_world = alive_world_val + end + ccall(:jl_method_table_insert, Cvoid, (Any, Any, Ptr{Cvoid}), methods_table, method, C_NULL) # i dont like doing this either! end - ccall(:jl_method_table_insert, Cvoid, (Any, Any, Ptr{Cvoid}), methods_table, method, C_NULL) # i dont like doing this either! end end return !isempty(methods(f).ms) @@ -859,10 +861,7 @@ function try_delete_toplevel_methods(workspace::Module, (cell_id, name_parts)::T end end -# these deal with some inconsistencies in Julia's internal (undocumented!) variable names -const primary_world = filter(in(fieldnames(Method)), [:primary_world, :min_world]) |> first # Julia v1.3 and v1.0 resp. -const deleted_world = filter(in(fieldnames(Method)), [:deleted_world, :max_world]) |> first # Julia v1.3 and v1.0 resp. -const alive_world_val = getfield(methods(Base.sqrt).ms[1], deleted_world) # typemax(UInt) in Julia v1.3, Int(-1) in Julia 1.0 +const alive_world_val = methods(Base.sqrt).ms[1].deleted_world # typemax(UInt) in Julia v1.3, Int(-1) in Julia 1.0 @@ -929,8 +928,7 @@ function formatted_result_of( errored = ans isa CapturedException output_formatted = if (!ends_with_semicolon || errored) - logger = get!(() -> PlutoCellLogger(notebook_id, cell_id), pluto_cell_loggers, cell_id) - with_logger_and_io_to_logs(logger; capture_stdout, stdio_loglevel=stdout_log_level) do + with_logger_and_io_to_logs(get_cell_logger(notebook_id, cell_id); capture_stdout) do format_output(ans; context=IOContext( default_iocontext, :extra_items=>extra_items, @@ -1002,6 +1000,7 @@ const default_iocontext = IOContext(devnull, :is_pluto => true, :pluto_supported_integration_features => supported_integration_features, :pluto_published_to_js => (io, x) -> core_published_to_js(io, x), + :pluto_with_js_link => (io, callback, on_cancellation) -> core_with_js_link(io, callback, on_cancellation), ) # `stdout` mimics a TTY, the only relevant property is :color @@ -1357,7 +1356,7 @@ end function show_richest_withreturned(context::IOContext, @nospecialize(args)) buffer = IOBuffer(; sizehint=0) val = show_richest(IOContext(buffer, context), args) - return (resize!(buffer.data, buffer.size), val) + return (take!(buffer), val) end "Super important thing don't change." @@ -1764,6 +1763,9 @@ const integrations = Integration[ if isdefined(AbstractPlutoDingetjes.Display, :published_to_js) supported!(AbstractPlutoDingetjes.Display.published_to_js) end + if isdefined(AbstractPlutoDingetjes.Display, :with_js_link) + supported!(AbstractPlutoDingetjes.Display.with_js_link) + end end end, @@ -1940,12 +1942,12 @@ completed_object_description(x::Module) = "Module" completed_object_description(x::AbstractArray) = "Array" completed_object_description(x::Any) = "Any" -completion_description(c::ModuleCompletion) = try +completion_value_type(c::ModuleCompletion) = try completed_object_description(getfield(c.parent, Symbol(c.mod))) catch nothing end -completion_description(::Completion) = nothing +completion_value_type(::Completion) = nothing completion_detail(::Completion) = nothing completion_detail(completion::BslashCompletion) = @@ -2009,38 +2011,49 @@ completion_from_notebook(c::ModuleCompletion) = !startswith(c.mod, "#") completion_from_notebook(c::Completion) = false -only_special_completion_types(::PathCompletion) = :path -only_special_completion_types(::DictCompletion) = :dict -only_special_completion_types(::Completion) = nothing +completion_type(::FuzzyCompletions.PathCompletion) = :path +completion_type(::FuzzyCompletions.DictCompletion) = :dict +completion_type(::FuzzyCompletions.MethodCompletion) = :method +completion_type(::FuzzyCompletions.ModuleCompletion) = :module +completion_type(::FuzzyCompletions.BslashCompletion) = :bslash +completion_type(::FuzzyCompletions.FieldCompletion) = :field +completion_type(::FuzzyCompletions.KeywordArgumentCompletion) = :keyword_argument +completion_type(::FuzzyCompletions.KeywordCompletion) = :keyword +completion_type(::FuzzyCompletions.PropertyCompletion) = :property +completion_type(::FuzzyCompletions.Text) = :text + +completion_type(::Completion) = nothing "You say Linear, I say Algebra!" function completion_fetcher(query, pos, workspace::Module) results, loc, found = completions(query, pos, workspace) - if endswith(query, '.') + partial = query[1:pos] + if endswith(partial, '.') filter!(is_dot_completion, results) # we are autocompleting a module, and we want to see its fields alphabetically sort!(results; by=(r -> completion_text(r))) - elseif endswith(query, '/') + elseif endswith(partial, '/') filter!(is_path_completion, results) sort!(results; by=(r -> completion_text(r))) - elseif endswith(query, '[') + elseif endswith(partial, '[') filter!(is_dict_completion, results) sort!(results; by=(r -> completion_text(r))) else isenough(x) = x ≥ 0 - filter!(isenough ∘ score, results) # too many candiates otherwise + filter!(r -> isenough(score(r)) && !is_path_completion(r), results) # too many candiates otherwise end exported = completions_exported(results) - smooshed_together = [ - (completion_text(result), - completion_description(result), - rexported, - completion_from_notebook(result), - only_special_completion_types(result), - completion_detail(result)) - for (result, rexported) in zip(results, exported) - ] + smooshed_together = map(zip(results, exported)) do (result, rexported) + ( + completion_text(result), + completion_value_type(result), + rexported, + completion_from_notebook(result), + completion_type(result), + completion_detail(result), + ) + end p = if endswith(query, '.') sortperm(smooshed_together; alg=MergeSort, by=basic_completion_priority) @@ -2188,7 +2201,7 @@ function improve_docs!(doc_md::Markdown.MD, query::Symbol, binding::Docs.Binding perm = sortperm(suggestions_scores; rev=true) permute!(suggestions, perm) - links = map(s -> Suggestion(s, symbol), @view(suggestions[begin:min(end,DOC_SUGGESTION_LIMIT)])) + links = map(s -> Suggestion(string(s), symbol), Iterators.take(suggestions, DOC_SUGGESTION_LIMIT)) if length(links) > 0 push!(doc_md.content, @@ -2547,6 +2560,73 @@ function Base.show(io::IO, m::MIME"text/html", e::DivElement) Base.show(io, m, embed_display(e)) end + +### +# JS LINK +### + +struct JSLink + callback::Function + on_cancellation::Union{Nothing,Function} + cancelled_ref::Ref{Bool} +end + +const cell_js_links = Dict{UUID,Dict{String,JSLink}}() + +function core_with_js_link(io, callback, on_cancellation) + + _cell_id = get(io, :pluto_cell_id, currently_running_cell_id[])::UUID + + link_id = String(rand('a':'z', 16)) + + links = get!(() -> Dict{String,JSLink}(), cell_js_links, _cell_id) + links[link_id] = JSLink(callback, on_cancellation, Ref(false)) + + write(io, "/* See the documentation for AbstractPlutoDingetjes.Display.with_js_link */ _internal_getJSLinkResponse(\"$(_cell_id)\", \"$(link_id)\")") +end + +function unregister_js_link(cell_id::UUID) + # cancel old links + old_links = get!(() -> Dict{String,JSLink}(), cell_js_links, cell_id) + for (name, link) in old_links + link.cancelled_ref[] = true + end + for (name, link) in old_links + c = link.on_cancellation + c === nothing || c() + end + + # clear + cell_js_links[cell_id] = Dict{String,JSLink}() +end + +function evaluate_js_link(notebook_id::UUID, cell_id::UUID, link_id::String, input::Any) + links = get(() -> Dict{String,JSLink}(), cell_js_links, cell_id) + link = get(links, link_id, nothing) + + with_logger_and_io_to_logs(get_cell_logger(notebook_id, cell_id); capture_stdout=false) do + if link === nothing + @warn "🚨 AbstractPlutoDingetjes: JS link not found." link_id + + (false, "link not found") + elseif link.cancelled_ref[] + @warn "🚨 AbstractPlutoDingetjes: JS link has already been invalidated." link_id + + (false, "link has been invalidated") + else + try + result = link.callback(input) + assertpackable(result) + + (true, result) + catch ex + @error "🚨 AbstractPlutoDingetjes.Display.with_js_link: Exception while evaluating Julia callback." input exception=(ex, catch_backtrace()) + (false, "exception in Julia callback:\n\n$(ex)") + end + end + end +end + ### # LOGGING ### @@ -2588,6 +2668,14 @@ end const pluto_cell_loggers = Dict{UUID,PlutoCellLogger}() # One logger per cell const pluto_log_channels = Dict{UUID,Channel{Any}}() # One channel per notebook +function get_cell_logger(notebook_id, cell_id) + logger = get!(() -> PlutoCellLogger(notebook_id, cell_id), pluto_cell_loggers, cell_id) + if logger.workspace_count < moduleworkspace_count[] + logger = pluto_cell_loggers[cell_id] = PlutoCellLogger(notebook_id, cell_id) + end + logger +end + function Logging.shouldlog(logger::PlutoCellLogger, level, _module, _...) # Accept logs # - Only if the logger is the latest for this cell using the increasing workspace_count tied to each logger @@ -2750,7 +2838,7 @@ function with_io_to_logs(f::Function; enabled::Bool=true, loglevel::Logging.LogL result end -function with_logger_and_io_to_logs(f, logger; capture_stdout=true, stdio_loglevel=Logging.LogLevel(1)) +function with_logger_and_io_to_logs(f, logger; capture_stdout=true, stdio_loglevel=stdout_log_level) Logging.with_logger(logger) do with_io_to_logs(f; enabled=capture_stdout, loglevel=stdio_loglevel) end diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl index 3708bc9e0c..4bb2c72e25 100644 --- a/src/webserver/Dynamic.jl +++ b/src/webserver/Dynamic.jl @@ -526,6 +526,29 @@ responses[:reshow_cell] = function response_reshow_cell(🙋::ClientRequest) send_notebook_changes!(🙋 |> without_initiator) end +responses[:request_js_link_response] = function response_request_js_link_response(🙋::ClientRequest) + require_notebook(🙋) + @assert will_run_code(🙋.notebook) + + Threads.@spawn try + result = WorkspaceManager.eval_fetch_in_workspace( + (🙋.session, 🙋.notebook), + quote + PlutoRunner.evaluate_js_link( + $(🙋.notebook.notebook_id), + $(UUID(🙋.body["cell_id"])), + $(🙋.body["link_id"]), + $(🙋.body["input"]), + ) + end + ) + + putclientupdates!(🙋.session, 🙋.initiator, UpdateMessage(:🐤, result, nothing, nothing, 🙋.initiator)) + catch ex + @error "Error in request_js_link_response" exception=(ex, stacktrace(catch_backtrace())) + end +end + responses[:nbpkg_available_versions] = function response_nbpkg_available_versions(🙋::ClientRequest) # require_notebook(🙋) all_versions = PkgCompat.package_versions(🙋.body["package_name"]) diff --git a/test/Configuration.jl b/test/Configuration.jl index 452a3a2839..8a8ccd045b 100644 --- a/test/Configuration.jl +++ b/test/Configuration.jl @@ -59,7 +59,7 @@ end @testset "Authentication" begin basic_nb_path = Pluto.project_relative_path("sample", "Basic.jl") - port = 1238 + port = 23832 options = Pluto.Configuration.from_flat_kwargs(; port, launch_browser=false, workspace_use_distributed=false) 🍭 = Pluto.ServerSession(; options) host = 🍭.options.server.host diff --git a/test/MacroAnalysis.jl b/test/MacroAnalysis.jl index 3d60dd71a7..b2be0e990e 100644 --- a/test/MacroAnalysis.jl +++ b/test/MacroAnalysis.jl @@ -164,7 +164,7 @@ import Memoize: @memoize temp_topology = Pluto.updated_topology(notebook.topology, notebook, notebook.cells) |> Pluto.static_resolve_topology - @test :f ∈ temp_topology.nodes[cell(1)].funcdefs_without_signatures + # @test :f ∈ temp_topology.nodes[cell(1)].funcdefs_without_signatures update_run!(🍭, notebook, notebook.cells) diff --git a/test/frontend/__tests__/javascript_api.js b/test/frontend/__tests__/javascript_api.js index 252132f37d..7c0b9b239d 100644 --- a/test/frontend/__tests__/javascript_api.js +++ b/test/frontend/__tests__/javascript_api.js @@ -45,7 +45,7 @@ describe("JavaScript API", () => { page, `# ╔═╡ 90cfa9a0-114d-49bf-8dea-e97d58fa2442 html"""""" @@ -53,7 +53,7 @@ describe("JavaScript API", () => { ) await runAllChanged(page) await waitForPlutoToCalmDown(page, { polling: 100 }) - const initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output`, expected) + const initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output find-me`, expected) expect(initialLastCellContent).toBe(expected) }) @@ -69,7 +69,7 @@ describe("JavaScript API", () => { ) await runAllChanged(page) await waitForPlutoToCalmDown(page, { polling: 100 }) - let initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output`, expected) + let initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output span`, expected) expect(initialLastCellContent).toBe(expected) await paste( @@ -84,7 +84,7 @@ describe("JavaScript API", () => { ) await runAllChanged(page) await waitForPlutoToCalmDown(page, { polling: 100 }) - initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output`, expected) + initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output span`, expected) expect(initialLastCellContent).toBe(expected) }) diff --git a/test/frontend/__tests__/slide_controls.js b/test/frontend/__tests__/slide_controls.js index 15a50e07ea..b4949ad828 100644 --- a/test/frontend/__tests__/slide_controls.js +++ b/test/frontend/__tests__/slide_controls.js @@ -45,7 +45,7 @@ describe("slideControls", () => { await importNotebook(page, "slides.jl", { permissionToRunCode: false }) const plutoCellIds = await getCellIds(page) const content = await waitForContent(page, `pluto-cell[id="${plutoCellIds[1]}"] pluto-output`) - expect(content).toBe("Slide 2") + expect(content).toBe("Slide 2\n") const slide_1_title = await page.$(`pluto-cell[id="${plutoCellIds[0]}"] pluto-output h1`) const slide_2_title = await page.$(`pluto-cell[id="${plutoCellIds[1]}"] pluto-output h1`) diff --git a/test/frontend/__tests__/wind_directions.js b/test/frontend/__tests__/wind_directions.js index b7a06bb688..b848e2b39d 100644 --- a/test/frontend/__tests__/wind_directions.js +++ b/test/frontend/__tests__/wind_directions.js @@ -95,14 +95,14 @@ describe("wind_directions", () => { ).toBe(expected) } - await expect_chosen_directions('chosen_directions_copy\n"North"') + await expect_chosen_directions('chosen_directions_copyString1"North"') expect(await page.evaluate((sel) => document.querySelector(sel).checked, checkbox_selector(0))).toBe(true) await page.click(checkbox_selector(2)) await waitForPlutoToCalmDown(page) - await expect_chosen_directions('chosen_directions_copy\n"North"\n"South"') + await expect_chosen_directions('chosen_directions_copyString1"North"2"South"') expect(await page.evaluate((sel) => document.querySelector(sel).checked, checkbox_selector(0))).toBe(true) expect(await page.evaluate((sel) => document.querySelector(sel).checked, checkbox_selector(1))).toBe(false) diff --git a/test/frontend/__tests__/with_js_link.js b/test/frontend/__tests__/with_js_link.js new file mode 100644 index 0000000000..c4f2c96216 --- /dev/null +++ b/test/frontend/__tests__/with_js_link.js @@ -0,0 +1,194 @@ +import puppeteer from "puppeteer" +import { saveScreenshot, createPage, waitForContentToBecome, getTextContent } from "../helpers/common" +import { + importNotebook, + getPlutoUrl, + shutdownCurrentNotebook, + setupPlutoBrowser, + getLogs, + getLogSelector, + writeSingleLineInPlutoInput, + runAllChanged, + waitForPlutoToCalmDown, +} from "../helpers/pluto" + +describe("with_js_link", () => { + /** + * Launch a shared browser instance for all tests. + * I don't use jest-puppeteer because it takes away a lot of control and works buggy for me, + * so I need to manually create the shared browser. + * @type {puppeteer.Browser} + */ + let browser = null + /** @type {puppeteer.Page} */ + let page = null + beforeAll(async () => { + browser = await setupPlutoBrowser() + page = await createPage(browser) + await page.goto(getPlutoUrl(), { waitUntil: "networkidle0" }) + + await importNotebook(page, "with_js_link.jl", { timeout: 120 * 1000 }) + }) + beforeEach(async () => {}) + afterEach(async () => { + await saveScreenshot(page) + }) + afterAll(async () => { + await shutdownCurrentNotebook(page) + await page.close() + page = null + await browser.close() + browser = null + }) + + const submit_ev_input = (id, value) => + page.evaluate( + (id, value) => { + document.querySelector(`.function_evaluator#${id} input`).value = value + + document.querySelector(`.function_evaluator#${id} input[type="submit"]`).click() + }, + id, + value + ) + + const ev_output_sel = (id) => `.function_evaluator#${id} textarea` + + const expect_ev_output = async (id, expected) => { + expect(await waitForContentToBecome(page, ev_output_sel(id), expected)).toBe(expected) + } + + it("basic", async () => { + ////// BASIC + await expect_ev_output("sqrt", "30") + await submit_ev_input("sqrt", "25") + await expect_ev_output("sqrt", "5") + }) + + // TODO test refresh + + // TODO RERUN cELL + + // TODO invalidation + + it("LOGS AND ERRORS", async () => { + ////// + let log_id = "33a2293c-6202-47ca-80d1-4a9e261cae7f" + const logs1 = await getLogs(page, log_id) + expect(logs1).toEqual([{ class: "Info", description: "you should see this log 4", kwargs: {} }]) + await submit_ev_input("logs1", "90") + + // TODO + await page.waitForFunction( + (sel) => { + return document.querySelector(sel).textContent.includes("90") + }, + { polling: 100 }, + getLogSelector(log_id) + ) + const logs2 = await getLogs(page, log_id) + expect(logs2).toEqual([ + { class: "Info", description: "you should see this log 4", kwargs: {} }, + { class: "Info", description: "you should see this log 90", kwargs: {} }, + ]) + }) + it("LOGS AND ERRORS 2", async () => { + const logs3 = await getLogs(page, "480aea45-da00-4e89-b43a-38e4d1827ec2") + expect(logs3.length).toEqual(2) + expect(logs3[0]).toEqual({ class: "Warn", description: "You should see the following error:", kwargs: {} }) + expect(logs3[1].class).toEqual("Error") + expect(logs3[1].description).toContain("with_js_link") + expect(logs3[1].kwargs.input).toEqual('"coOL"') + expect(logs3[1].kwargs.exception).toContain("You should see this error COOL") + }) + it("LOGS AND ERRORS 3: assertpackable", async () => { + const logs = await getLogs(page, "b310dd30-dddd-4b75-81d2-aaf35c9dd1d3") + expect(logs.length).toEqual(2) + expect(logs[0]).toEqual({ class: "Warn", description: "You should see the assertpackable fail after this log", kwargs: {} }) + expect(logs[1].class).toEqual("Error") + expect(logs[1].description).toContain("with_js_link") + expect(logs[1].kwargs.input).toEqual('"4"') + expect(logs[1].kwargs.exception).toContain("Only simple objects can be shared with JS") + }) + + it("globals", async () => { + await expect_ev_output("globals", "54") + }) + it("multiple in one cell", async () => { + await expect_ev_output("uppercase", "ΠΑΝΑΓΙΏΤΗΣ") + await expect_ev_output("lowercase", "παναγιώτης") + + await submit_ev_input("uppercase", "wOw") + + await expect_ev_output("uppercase", "WOW") + await expect_ev_output("lowercase", "παναγιώτης") + + await submit_ev_input("lowercase", "drOEF") + + await expect_ev_output("uppercase", "WOW") + await expect_ev_output("lowercase", "droef") + }) + it("repeated", async () => { + await expect_ev_output(`length[cellid="40031867-ee3c-4aa9-884f-b76b5a9c4dec"]`, "7") + await expect_ev_output(`length[cellid="7f6ada79-8e3b-40b7-b477-ce05ae79a668"]`, "7") + + await submit_ev_input(`length[cellid="40031867-ee3c-4aa9-884f-b76b5a9c4dec"]`, "yay") + + await expect_ev_output(`length[cellid="40031867-ee3c-4aa9-884f-b76b5a9c4dec"]`, "3") + await expect_ev_output(`length[cellid="7f6ada79-8e3b-40b7-b477-ce05ae79a668"]`, "7") + }) + + it("concurrency", async () => { + await expect_ev_output("c1", "C1") + await expect_ev_output("c2", "C2") + + await submit_ev_input("c1", "cc1") + await submit_ev_input("c2", "cc2") + + await page.waitForTimeout(4000) + + // NOT + // they dont run in parallel so right now only cc1 should be finished + // expect(await page.evaluate((s) => document.querySelector(s).textContent, ev_output_sel("c1"))).toBe("CC1") + // expect(await page.evaluate((s) => document.querySelector(s).textContent, ev_output_sel("c2"))).toBe("C2") + + // await expect_ev_output("c1", "CC1") + // await expect_ev_output("c2", "CC2") + + // they should run in parallel: after 4 seconds both should be finished + expect(await page.evaluate((s) => document.querySelector(s).textContent, ev_output_sel("c1"))).toBe("CC1") + expect(await page.evaluate((s) => document.querySelector(s).textContent, ev_output_sel("c2"))).toBe("CC2") + }) + + const expect_jslog = async (expected) => { + expect(await waitForContentToBecome(page, "#checkme", expected)).toBe(expected) + } + it("js errors", async () => { + await waitForPlutoToCalmDown(page) + await page.waitForTimeout(100) + await expect_jslog("hello!") + await page.click("#jslogbtn") + await page.waitForTimeout(500) + await page.click("#jslogbtn") + await page.waitForTimeout(100) + + // We clicked twice, but sometimes it only registers one click for some reason. I don't care, so let's check for either. + let prefix = await Promise.race([ + waitForContentToBecome(page, "#checkme", "hello!clickyay KRATJE"), + waitForContentToBecome(page, "#checkme", "hello!clickclickyay KRATJEyay KRATJE"), + ]) + + const yolotriggerid = "8782cc14-eb1a-48a8-a114-2f71f77be275" + await page.click(`pluto-cell[id="${yolotriggerid}"] pluto-output input[type="button"]`) + await expect_jslog(`${prefix}hello!`) + await page.click("#jslogbtn") + await expect_jslog(`${prefix}hello!clicknee exception in Julia callback:ErrorException("bad")`) + + await page.click("#jslogbtn") + await page.waitForTimeout(500) + + await page.click(`pluto-cell[id="${yolotriggerid}"] .runcell`) + + await expect_jslog(`${prefix}hello!clicknee exception in Julia callback:ErrorException("bad")clickhello!nee link not found`) + }) +}) diff --git a/test/frontend/fixtures/slides.jl b/test/frontend/fixtures/slides.jl index d903c7dce1..fe0f3262e7 100644 --- a/test/frontend/fixtures/slides.jl +++ b/test/frontend/fixtures/slides.jl @@ -1,5 +1,5 @@ ### A Pluto.jl notebook ### -# v0.11.14 +# v0.19.40 using Markdown using InteractiveUtils diff --git a/test/frontend/fixtures/wind_directions.jl b/test/frontend/fixtures/wind_directions.jl index 7f3d48f700..a3f805598c 100644 --- a/test/frontend/fixtures/wind_directions.jl +++ b/test/frontend/fixtures/wind_directions.jl @@ -1,5 +1,5 @@ ### A Pluto.jl notebook ### -# v0.19.31 +# v0.19.40 using Markdown using InteractiveUtils @@ -679,6 +679,7 @@ version = "1.2.0" [[ArgTools]] uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" +version = "1.1.1" [[Artifacts]] uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" @@ -698,6 +699,11 @@ git-tree-sha1 = "532c4185d3c9037c0237546d817858b23cf9e071" uuid = "a80b9123-70ca-4bc0-993e-6e3bcb318db6" version = "0.8.12" +[[CompilerSupportLibraries_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" +version = "1.0.5+1" + [[Crayons]] git-tree-sha1 = "249fe38abf76d48563e2f4556bebd215aa317e15" uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" @@ -708,8 +714,12 @@ deps = ["Printf"] uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" [[Downloads]] -deps = ["ArgTools", "LibCURL", "NetworkOptions"] +deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"] uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" +version = "1.6.0" + +[[FileWatching]] +uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" [[FixedPointNumbers]] deps = ["Statistics"] @@ -748,24 +758,32 @@ version = "0.21.4" [[LibCURL]] deps = ["LibCURL_jll", "MozillaCACerts_jll"] uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" +version = "0.6.4" [[LibCURL_jll]] deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"] uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" +version = "8.4.0+0" [[LibGit2]] -deps = ["Base64", "NetworkOptions", "Printf", "SHA"] +deps = ["Base64", "LibGit2_jll", "NetworkOptions", "Printf", "SHA"] uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" +[[LibGit2_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll"] +uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5" +version = "1.6.4+0" + [[LibSSH2_jll]] deps = ["Artifacts", "Libdl", "MbedTLS_jll"] uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" +version = "1.11.0+1" [[Libdl]] uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" [[LinearAlgebra]] -deps = ["Libdl"] +deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" [[Logging]] @@ -789,15 +807,23 @@ version = "0.1.1" [[MbedTLS_jll]] deps = ["Artifacts", "Libdl"] uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" +version = "2.28.2+1" [[Mmap]] uuid = "a63ad114-7e13-5084-954f-fe012c677804" [[MozillaCACerts_jll]] uuid = "14a3606d-f60d-562e-9121-12d972cd8159" +version = "2023.1.10" [[NetworkOptions]] uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" +version = "1.2.0" + +[[OpenBLAS_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] +uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" +version = "0.3.23+2" [[Parsers]] deps = ["Dates", "PrecompileTools", "UUIDs"] @@ -806,8 +832,9 @@ uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" version = "2.7.2" [[Pkg]] -deps = ["Artifacts", "Dates", "Downloads", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "Serialization", "TOML", "Tar", "UUIDs", "p7zip_jll"] +deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "Serialization", "TOML", "Tar", "UUIDs", "p7zip_jll"] uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +version = "1.10.0" [[PlutoUI]] deps = ["AbstractPlutoDingetjes", "Base64", "ColorTypes", "Dates", "FixedPointNumbers", "Hyperscript", "HypertextLiteral", "IOCapture", "InteractiveUtils", "JSON", "Logging", "MIMEs", "Markdown", "Random", "Reexport", "URIs", "UUIDs"] @@ -836,7 +863,7 @@ deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"] uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" [[Random]] -deps = ["Serialization"] +deps = ["SHA"] uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" [[Reexport]] @@ -846,6 +873,7 @@ version = "1.2.2" [[SHA]] uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" [[Serialization]] uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" @@ -854,20 +882,29 @@ uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" uuid = "6462fe0b-24de-5631-8697-dd941f90decc" [[SparseArrays]] -deps = ["LinearAlgebra", "Random"] +deps = ["Libdl", "LinearAlgebra", "Random", "Serialization", "SuiteSparse_jll"] uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" +version = "1.10.0" [[Statistics]] deps = ["LinearAlgebra", "SparseArrays"] uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +version = "1.10.0" + +[[SuiteSparse_jll]] +deps = ["Artifacts", "Libdl", "libblastrampoline_jll"] +uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" +version = "7.2.1+1" [[TOML]] deps = ["Dates"] uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.3" [[Tar]] deps = ["ArgTools", "SHA"] uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" +version = "1.10.0" [[Test]] deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] @@ -893,14 +930,22 @@ uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" [[Zlib_jll]] deps = ["Libdl"] uuid = "83775a58-1f1d-513f-b197-d71354ab007a" +version = "1.2.13+1" + +[[libblastrampoline_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850b90-86db-534c-a0d3-1478176c7d93" +version = "5.8.0+1" [[nghttp2_jll]] deps = ["Artifacts", "Libdl"] uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" +version = "1.52.0+1" [[p7zip_jll]] deps = ["Artifacts", "Libdl"] uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" +version = "17.4.0+2" """ # ╔═╡ Cell order: diff --git a/test/frontend/fixtures/with_js_link.jl b/test/frontend/fixtures/with_js_link.jl new file mode 100644 index 0000000000..6db0b7db03 --- /dev/null +++ b/test/frontend/fixtures/with_js_link.jl @@ -0,0 +1,431 @@ +### A Pluto.jl notebook ### +# v0.19.40 + +using Markdown +using InteractiveUtils + +# This Pluto notebook uses @bind for interactivity. When running this notebook outside of Pluto, the following 'mock version' of @bind gives bound variables a default value (instead of an error). +macro bind(def, element) + quote + local iv = try Base.loaded_modules[Base.PkgId(Base.UUID("6e696c72-6542-2067-7265-42206c756150"), "AbstractPlutoDingetjes")].Bonds.initial_value catch; b -> missing; end + local el = $(esc(element)) + global $(esc(def)) = Core.applicable(Base.get, el) ? Base.get(el) : iv(el) + el + end +end + +# ╔═╡ b0f2a778-885f-11ee-3d28-939ca4069ee8 +begin + import Pkg + Pkg.activate(temp=true) + Pkg.add([ + Pkg.PackageSpec(name="AbstractPlutoDingetjes") + Pkg.PackageSpec(name="HypertextLiteral") + Pkg.PackageSpec(name="PlutoUI") + ]) + + using AbstractPlutoDingetjes + using PlutoUI + using HypertextLiteral +end + +# ╔═╡ 5e42ea32-a1ce-49db-b55f-5e252c8c3f57 +using Dates + +# ╔═╡ 37aacc7f-61fd-4c4b-b24d-42361d508e8d +@htl(""" + +""") + +# ╔═╡ 30d7c350-f792-47e9-873a-01adf909bc84 +md""" +If you change `String` to `AbstractString` here then you get some back logs: +""" + +# ╔═╡ 75752f77-1e3f-4997-869b-8bee2c12a2cb +function cool(x::String) + uppercase(x) +end + +# ╔═╡ 3098e16a-4730-4564-a484-02a6b0278930 +# function cool() +# end + +# ╔═╡ 37fc039e-7a4d-4d2d-80f3-d409a9ee096d +# ╠═╡ disabled = true +#=╠═╡ +# let +# function f(x) +# cool(x) +# end +# @htl(""" +# +# """) +# end + ╠═╡ =# + +# ╔═╡ 977c59f7-9f3a-40ae-981d-2a8a48e08349 + + +# ╔═╡ b3186d7b-8fd7-4575-bccf-8e89ce611010 +md""" +# Benchmark + +We call the `next` function from JavaScript in a loop until `max` is reached, to calculate the time of each round trip. +""" + +# ╔═╡ 82c7a083-c84d-4924-bad2-776d3cdad797 +next(x) = x + 1; + +# ╔═╡ e8abaff9-f629-47c6-8009-066bcdf67693 +max = 250; + +# ╔═╡ bf9861e0-be91-4041-aa61-8ac2ef6cb719 +@htl(""" +
+

+

Current value:

+

Past values:

+

Time per round trip:

+ +
+""") + +# ╔═╡ ebf79ee4-2590-4b5a-a957-213ed03a5921 +md""" +# Concurrency +""" + +# ╔═╡ 60444c4c-5705-4b92-8eac-2c102f14f395 + + +# ╔═╡ 07c832c1-fd8f-44de-bdfa-389048c1e4e9 +md""" +## With a function in the closure +""" + +# ╔═╡ 10d80b00-f7ab-4bd7-9ea7-cca98c089e9c +coolthing(x) = x + +# ╔═╡ bf7a885e-4d0a-408d-b6d5-d3289d794240 +try + sqrt(-1) +catch e + sprint(showerror, e) +end + +# ╔═╡ 0eff37d6-9cd5-42bb-b274-de364ca7ed53 + + +# ╔═╡ 663e5a70-4d07-4d6a-8725-dc9a2b26b65d +md""" +# Tests +""" + +# ╔═╡ 1d32fd55-9ca0-45c8-97f5-23cb29eaa8b3 +md""" +Test a closure +""" + +# ╔═╡ 5f3c590e-07f2-4dea-b6d1-e9d90f501fda +some_other_global = rand(100) + +# ╔═╡ 3d836ff3-995e-4353-807e-bf2cd78920e2 +some_global = 51:81 + +# ╔═╡ 2461d75e-81dc-4e00-99e3-bbc44000579f +AbstractPlutoDingetjes.Display.with_js_link(x -> x) + +# ╔═╡ 12e64b86-3866-4e21-9af5-0e546452b4e1 +function function_evaluator(f::Function, default=""; id=string(f)) + @htl(""" +
+

Input:
+  

+ +

Output:
+ + + +

+ """) +end + +# ╔═╡ 4b80dda0-74b6-4a0e-a50e-61c5380111a4 +function_evaluator(900; id="sqrt") do input + num = parse(Float64, input) + sqrt(num) +end + +# ╔═╡ a399cb12-39d4-43c0-a0a7-05cb683dffbd +function_evaluator("c1"; id="c1") do input + @info "start" Dates.now() + sleep(3) + # peakflops(3000) + + + @warn "end" Dates.now() + uppercase(input) + +end + +# ╔═╡ 2bff3975-5918-40fe-9761-eb7b47f16df2 +function_evaluator("c2"; id="c2") do input + @info "start" Dates.now() + sleep(3) + # peakflops(3000) + + @warn "end" Dates.now() + uppercase(input) +end + +# ╔═╡ 53e60352-3a56-4b5c-9568-1ac58b758497 +function_evaluator("hello") do str + sleep(5) + result = coolthing(str) + @info result + result +end + +# ╔═╡ 2b5cc4b1-ca57-4cb6-a42a-dcb331ed2c26 +let + thing = function_evaluator("1") do str + some_other_global[1:parse(Int,str)] + end + if false + fff() = 123 + end + thing +end + +# ╔═╡ 85e9daf1-d8e3-4fc0-8acd-10d863e724d0 +let + x = rand(100) + function_evaluator("1") do str + x[parse(Int,str)] + end +end + +# ╔═╡ abb24301-357c-40f0-832e-86f26404d3d9 +function_evaluator("THIS IN LOWERCASE") do input + "you should see $(lowercase(input))" +end + +# ╔═╡ 33a2293c-6202-47ca-80d1-4a9e261cae7f +function_evaluator(4; id="logs1") do input + @info "you should see this log $(input)" + println("(not currently supported) you should see this print $(input)") + + rand(parse(Int, input)) +end + +# ╔═╡ 480aea45-da00-4e89-b43a-38e4d1827ec2 +function_evaluator("coOL") do input + @warn("You should see the following error:") + + error("You should see this error $(uppercase(input))") +end + +# ╔═╡ b310dd30-dddd-4b75-81d2-aaf35c9dd1d3 +function_evaluator(4) do input + @warn("You should see the assertpackable fail after this log") + + :(@heyyy cant msgpack me) +end + +# ╔═╡ 58999fba-6631-4482-a811-12bf2412d65e +function_evaluator(4; id="globals") do input + some_global[parse(Int, input)] +end + +# ╔═╡ 9e5c0f8d-6ac1-4aee-a00d-938f17eec146 +md""" +You should be able to use `with_js_link` multiple times within one cell, and they should work independently of eachother: +""" + +# ╔═╡ 306d03da-cd50-4b0c-a5dd-7ec1a278cde1 +@htl(""" +
+ $(function_evaluator(uppercase, "Παναγιώτης")) + $(function_evaluator(lowercase, "Παναγιώτης")) +
+""") + +# ╔═╡ 2cf033a7-bcd7-434d-9faf-ea761897fb64 +md""" +You should be able to set up a `with_js_link` in one cell, and use it in another. This example is a bit trivial though... +""" + +# ╔═╡ 40031867-ee3c-4aa9-884f-b76b5a9c4dec +fe = function_evaluator(length, "Alberto") + +# ╔═╡ 7f6ada79-8e3b-40b7-b477-ce05ae79a668 +fe + +# ╔═╡ f344c4cb-8226-4145-ab92-a37542f697dd +md""" +You should see a warning message when `with_js_link` is not used inside an HTML renderer that supports it: +""" + +# ╔═╡ 8bbd32f8-56f7-4f29-aea8-6906416f6cfd +let + html_repr = repr(MIME"text/html"(), fe) + HTML(html_repr) +end + +# ╔═╡ 8782cc14-eb1a-48a8-a114-2f71f77be275 +@bind yolotrigger CounterButton() + +# ╔═╡ e5df2451-f4b9-4511-b25f-1a5e463f3eb2 +name = yolotrigger > 0 ? "krat" : "kratje" + +# ╔═╡ 3c5c1325-ad3e-4c54-8d29-c17939bb8529 +function useme(x) + length(x) > 5 ? uppercase(x) : error("bad") +end + +# ╔═╡ 6c5f79b9-598d-41ad-800d-0a9ff63d6f6c +@htl(""" + + +""") + +# ╔═╡ Cell order: +# ╠═b0f2a778-885f-11ee-3d28-939ca4069ee8 +# ╠═4b80dda0-74b6-4a0e-a50e-61c5380111a4 +# ╠═37aacc7f-61fd-4c4b-b24d-42361d508e8d +# ╟─30d7c350-f792-47e9-873a-01adf909bc84 +# ╠═75752f77-1e3f-4997-869b-8bee2c12a2cb +# ╠═3098e16a-4730-4564-a484-02a6b0278930 +# ╠═37fc039e-7a4d-4d2d-80f3-d409a9ee096d +# ╠═977c59f7-9f3a-40ae-981d-2a8a48e08349 +# ╟─b3186d7b-8fd7-4575-bccf-8e89ce611010 +# ╠═82c7a083-c84d-4924-bad2-776d3cdad797 +# ╠═e8abaff9-f629-47c6-8009-066bcdf67693 +# ╟─bf9861e0-be91-4041-aa61-8ac2ef6cb719 +# ╟─ebf79ee4-2590-4b5a-a957-213ed03a5921 +# ╠═a399cb12-39d4-43c0-a0a7-05cb683dffbd +# ╠═5e42ea32-a1ce-49db-b55f-5e252c8c3f57 +# ╠═60444c4c-5705-4b92-8eac-2c102f14f395 +# ╠═2bff3975-5918-40fe-9761-eb7b47f16df2 +# ╟─07c832c1-fd8f-44de-bdfa-389048c1e4e9 +# ╠═10d80b00-f7ab-4bd7-9ea7-cca98c089e9c +# ╠═53e60352-3a56-4b5c-9568-1ac58b758497 +# ╠═bf7a885e-4d0a-408d-b6d5-d3289d794240 +# ╠═0eff37d6-9cd5-42bb-b274-de364ca7ed53 +# ╟─663e5a70-4d07-4d6a-8725-dc9a2b26b65d +# ╟─1d32fd55-9ca0-45c8-97f5-23cb29eaa8b3 +# ╠═5f3c590e-07f2-4dea-b6d1-e9d90f501fda +# ╠═2b5cc4b1-ca57-4cb6-a42a-dcb331ed2c26 +# ╠═85e9daf1-d8e3-4fc0-8acd-10d863e724d0 +# ╠═abb24301-357c-40f0-832e-86f26404d3d9 +# ╠═33a2293c-6202-47ca-80d1-4a9e261cae7f +# ╠═480aea45-da00-4e89-b43a-38e4d1827ec2 +# ╠═b310dd30-dddd-4b75-81d2-aaf35c9dd1d3 +# ╠═3d836ff3-995e-4353-807e-bf2cd78920e2 +# ╠═58999fba-6631-4482-a811-12bf2412d65e +# ╠═2461d75e-81dc-4e00-99e3-bbc44000579f +# ╠═12e64b86-3866-4e21-9af5-0e546452b4e1 +# ╟─9e5c0f8d-6ac1-4aee-a00d-938f17eec146 +# ╠═306d03da-cd50-4b0c-a5dd-7ec1a278cde1 +# ╟─2cf033a7-bcd7-434d-9faf-ea761897fb64 +# ╠═40031867-ee3c-4aa9-884f-b76b5a9c4dec +# ╠═7f6ada79-8e3b-40b7-b477-ce05ae79a668 +# ╟─f344c4cb-8226-4145-ab92-a37542f697dd +# ╠═8bbd32f8-56f7-4f29-aea8-6906416f6cfd +# ╠═8782cc14-eb1a-48a8-a114-2f71f77be275 +# ╠═e5df2451-f4b9-4511-b25f-1a5e463f3eb2 +# ╠═3c5c1325-ad3e-4c54-8d29-c17939bb8529 +# ╠═6c5f79b9-598d-41ad-800d-0a9ff63d6f6c diff --git a/test/frontend/helpers/common.js b/test/frontend/helpers/common.js index c0578974b4..c2739c6b28 100644 --- a/test/frontend/helpers/common.js +++ b/test/frontend/helpers/common.js @@ -66,7 +66,7 @@ const with_connections_debug = (page, action) => { export const getTextContent = (page, selector) => { // https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#differences_from_innertext return page.evaluate( - (selector) => document.querySelector(selector).innerText, + (selector) => document.querySelector(selector)?.textContent, selector ); }; @@ -127,18 +127,23 @@ export const waitForContentToChange = async ( return getTextContent(page, selector); }; -export const waitForContentToBecome = async (page, selector, targetContent) => { +export const waitForContentToBecome = async (/** @type {puppeteer.Page} */ page, /** @type {string} */ selector, /** @type {string} */ targetContent) => { await page.waitForSelector(selector, { visible: true }); - await page.waitForFunction( + try{ + await page.waitForFunction( (selector, targetContent) => { const element = document.querySelector(selector); // https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#differences_from_innertext - return element !== null && element.innerText === targetContent; + return element !== null && element.textContent === targetContent; }, { polling: 100 }, selector, targetContent ); + } catch(e) { + console.error("Failed! Current content: ", JSON.stringify(await getTextContent(page, selector)), "Expected content: ", JSON.stringify(targetContent)) + throw(e) + } return getTextContent(page, selector); }; @@ -211,7 +216,7 @@ export const createPage = async (browser) => { return page }; -let testname = () => expect.getState()?.currentTestName?.replace(/ /g, "_") ?? "unnkown"; +let testname = () => expect.getState()?.currentTestName?.replace(/[ \:]/g, "_") ?? "unnkown"; export const lastElement = (arr) => arr[arr.length - 1]; diff --git a/test/frontend/helpers/pluto.js b/test/frontend/helpers/pluto.js index ea6ce564d1..0a7d55f56c 100644 --- a/test/frontend/helpers/pluto.js +++ b/test/frontend/helpers/pluto.js @@ -192,6 +192,24 @@ export const waitForNoUpdateOngoing = async (page, options = {}) => { ) } +export const getLogSelector = (cellId) => `pluto-cell[id="${cellId}"] pluto-logs` + +export const getLogs = async (page, cellid) => { + return await page.evaluate((sel) => { + const logs = document.querySelector(sel) + return Array.from(logs.children).map((el) => ({ + class: el.className.trim(), + description: el.querySelector("pluto-log-dot > pre").textContent, + kwargs: Object.fromEntries( + Array.from(el.querySelectorAll("pluto-log-dot-kwarg")).map((x) => [ + x.querySelector("pluto-key").textContent, + x.querySelector("pluto-value").textContent, + ]) + ), + })) + }, getLogSelector(cellid)) +} + /** * @param {Page} page */ diff --git a/test/frontend/jest.config.js b/test/frontend/jest.config.js index b4edfb11b6..93fb8f86da 100644 --- a/test/frontend/jest.config.js +++ b/test/frontend/jest.config.js @@ -1,4 +1,4 @@ module.exports = { - testTimeout: 100000, + testTimeout: 300000, slowTestThreshold: 30, } diff --git a/test/runtests.jl b/test/runtests.jl index e1bf62b574..ee45e34881 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -12,13 +12,13 @@ end verify_no_running_processes() @timeit_include("Configuration.jl") verify_no_running_processes() -@timeit_include("packages/Basic.jl") +@timeit_include("React.jl") verify_no_running_processes() @timeit_include("Bonds.jl") verify_no_running_processes() @timeit_include("RichOutput.jl") verify_no_running_processes() -@timeit_include("React.jl") +@timeit_include("packages/Basic.jl") verify_no_running_processes() @timeit_include("Dynamic.jl") verify_no_running_processes() @@ -53,12 +53,3 @@ verify_no_running_processes() print_timeroutput() @timeit_include("ExpressionExplorer.jl") -# TODO: test PlutoRunner functions like: -# - from_this_notebook - -# TODO: test include() inside notebooks - -# TODO: test async execution order -# TODO: test @bind - -# TODO: test if notebooks are saved correctly after edits