diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 501c1cf59..b09441068 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -107,6 +107,45 @@ jobs: packages/solara-vuetify-app/dist packages/solara-vuetify3-app/dist + - name: Publish dev wheels to object storage + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} + AWS_REGION: nbg1 + AWS_ENDPOINT_URL_S3: https://nbg1.your-objectstorage.com + S3_BUCKET: ipyvue3-packages + run: | + python -m pip install --upgrade awscli + + aws s3 cp dist/ "s3://${S3_BUCKET}/packages/solara/" \ + --recursive \ + --exclude "*" \ + --include "*.whl" \ + --endpoint-url "$AWS_ENDPOINT_URL_S3" \ + --region "$AWS_REGION" + + aws s3 cp packages/solara-meta/dist/ "s3://${S3_BUCKET}/packages/solara-meta/" \ + --recursive \ + --exclude "*" \ + --include "*.whl" \ + --endpoint-url "$AWS_ENDPOINT_URL_S3" \ + --region "$AWS_REGION" + + aws s3 cp packages/solara-server/dist/ "s3://${S3_BUCKET}/packages/solara-server/" \ + --recursive \ + --exclude "*" \ + --include "*.whl" \ + --endpoint-url "$AWS_ENDPOINT_URL_S3" \ + --region "$AWS_REGION" + + aws s3 cp packages/pytest-ipywidgets/dist/ "s3://${S3_BUCKET}/packages/pytest-ipywidgets/" \ + --recursive \ + --exclude "*" \ + --include "*.whl" \ + --endpoint-url "$AWS_ENDPOINT_URL_S3" \ + --region "$AWS_REGION" + code-quality: runs-on: ubuntu-latest strategy: @@ -534,7 +573,7 @@ jobs: pip install "jupyterlab<4" "playwright==1.50.0" "pydantic<2" "ipywidgets~=${{ matrix.ipywidgets }}" pip install pytest-retry pip install jupyter_core jupyter-packaging - pip install --pre "ipyvue>=3" "ipyvuetify>=3" + pip install --pre "ipyvuetify>=3.0.0a4" pip freeze --exclude solara --exclude solara-ui --exclude solara-server --exclude pytest-ipywidgets --exclude solara-enterprise > ${{ env.LOCK_FILE_LOCATION }} git diff | tee ${{ env.DIFF_FILE_LOCATION }} [ -s ${{ env.DIFF_FILE_LOCATION }} ] && echo "HAS_DIFF=true" >> "$GITHUB_OUTPUT" || echo "No dependencies changed" diff --git a/packages/pytest-ipywidgets/pyproject.toml b/packages/pytest-ipywidgets/pyproject.toml index 815f3f67d..24dae56a5 100644 --- a/packages/pytest-ipywidgets/pyproject.toml +++ b/packages/pytest-ipywidgets/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "playwright", "pytest-playwright", "pillow", - "pixelmatch", + "pixelmatch==0.3.0", ] [project.urls] diff --git a/packages/solara-vuetify3-app/src/solara-vuetify-app.js b/packages/solara-vuetify3-app/src/solara-vuetify-app.js index 96a8ce35f..b6e733670 100644 --- a/packages/solara-vuetify3-app/src/solara-vuetify-app.js +++ b/packages/solara-vuetify3-app/src/solara-vuetify-app.js @@ -3,10 +3,14 @@ import * as Vuetify from 'vuetify'; import 'vuetify/dist/vuetify.min.css'; import * as components from 'vuetify/components'; +import * as labComponents from 'vuetify/labs/components'; import * as directives from 'vuetify/directives'; const vuetifyPlugin = Vuetify.createVuetify({ - components, + components: { + ...components, + ...labComponents, + }, directives, }); diff --git a/pyproject.toml b/pyproject.toml index 5636b9ddd..34f8aedd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,8 @@ packages = [ dependencies = [ "reacton>=1.9", "ipywidgets>=7.7", - "ipyvuetify>=1.6.10", - "ipyvue>=1.9.0", + "ipyvue>=3.0.0a6", + "ipyvuetify>=3.0.0a4", "requests", "humanize", ] diff --git a/solara/components/file_list_widget.vue b/solara/components/file_list_widget.vue index 47c430102..c932a63a9 100644 --- a/solara/components/file_list_widget.vue +++ b/solara/components/file_list_widget.vue @@ -10,17 +10,16 @@ :key="name + '|' + is_file" @click.stop="clicked = { name, is_file }" @dblclick="double_clicked = { name, is_file }" - :class="(clicked && clicked.name == name) ? 'solara-file-list-selected': ''" + :class="['solara-file-list-item', (clicked && clicked.name == name) ? 'solara-file-list-selected': '']" > - - {{ name === '..' ? 'mdi-keyboard-backspace' : is_file ? 'mdi-file-document' : 'mdi-folder' }} - - - +
+
+ {{ name === '..' ? 'mdi-keyboard-backspace' : is_file ? 'mdi-file-document' : 'mdi-folder' }} +
{{ name }} - {{ size }} - +
@@ -60,19 +59,31 @@ module.exports = { } -.solara-file-list .v-list-item__icon, -.solara-file-list .v-list-item__list { - margin-top: 0; - margin-bottom: 0; +.solara-file-list .solara-file-list-row { + align-items: center; + display: flex; + min-height: 28px; + width: 100%; } -.v-application--is-ltr .solara-file-list .v-list-item__icon { +.solara-file-list .solara-file-list-icon { + align-items: center; + display: flex; + flex: 0 0 32px; + justify-content: center; margin-right: 8px; } +.solara-file-list .solara-file-list-item.v-list-item, .solara-file-list .v-list-item { height: 28px; min-height: 0; - padding-left: 0; + padding: 0; + padding-inline-end: 0; + padding-inline-start: 0; +} + +.solara-file-list .v-list-item-title { + line-height: 28px; } diff --git a/solara/server/server.py b/solara/server/server.py index 4f586f809..bcb071017 100644 --- a/solara/server/server.py +++ b/solara/server/server.py @@ -346,8 +346,10 @@ def include_js(path: str, module=False) -> Markup: content, hash = solara.util.get_file_hash(filepath) content_utf8 = content.decode("utf-8") url = f"{root_path}{path}?v={hash}" - # when < 10k we embed, but if we use currentScript, it can break things - embed = len(content) < 1024 * 10 and b"currentScript" not in content + # when < 20k we embed, but if we use currentScript, it can break things + # main-vuetify.js must be embedded on the Jupyter server extension page, + # where /solara/static/main-vuetify.js is not a valid static asset URL. + embed = (len(content) < 1024 * 20 or path == "/static/main-vuetify.js") and b"currentScript" not in content if embed: if module: code = f'' diff --git a/solara/server/static/main-vuetify.js b/solara/server/static/main-vuetify.js index d4bce7148..40bcc0a8c 100644 --- a/solara/server/static/main-vuetify.js +++ b/solara/server/static/main-vuetify.js @@ -4,6 +4,7 @@ var jupyterWidgetMountPoint = { return { renderFn: undefined, elem: undefined, + component: undefined, } }, props: ['mount-id'], @@ -11,11 +12,18 @@ var jupyterWidgetMountPoint = { requestWidget(this.mountId); }, mounted() { - const vue3 = Vue.version.startsWith('3'); requestWidget(this.mountId) - .then(widgetView => { - if (['VuetifyView', 'VuetifyTemplateView'].includes(widgetView.model.get('_view_name'))) { - this.renderFn = createElement => widgetView.vueRender(createElement); + .then(async widgetView => { + const model = widgetView.model; + if (['VuetifyView', 'VuetifyTemplateView'].includes(model.get('_view_name'))) { + if (['VueTemplateModel', 'VuetifyTemplateModel'].includes(model.get('_model_name'))) { + await registerVueComponents(this, widgetView); + } + if (Vue.h) { + this.component = widgetView.vueComponent(); + } else { + this.renderFn = createElement => widgetView.vueRender(createElement); + } } else { while (this.$el.firstChild) { this.$el.removeChild(this.$el.firstChild); @@ -31,6 +39,9 @@ var jupyterWidgetMountPoint = { render(createElement) { // in vue3 we have Vue.h, otherwise fall back to createElement (vue2) let h = Vue.h || createElement; + if (this.component) { + return h(this.component); + } if (this.renderFn) { /* workaround for v-menu click */ if (!this.elem) { @@ -43,6 +54,28 @@ var jupyterWidgetMountPoint = { } }; +async function registerVueComponents(vueComponent, widgetView) { + const app = vueComponent.$.appContext.app; + await loadWidgetModule('jupyter-vue', jupyterVue => + jupyterVue.addApp(app, widgetView.model.widget_manager) + ); + if (widgetView.model.get('_view_module') === 'jupyter-vuetify') { + await loadWidgetModule('jupyter-vuetify', jupyterVuetify => + jupyterVuetify.addApp(app) + ); + } +} + +function loadWidgetModule(name, callback) { + return new Promise(resolve => { + if (!requirejs.defined(name) && !requirejs.specified(name)) { + resolve(); + return; + } + requirejs([name], module => resolve(callback(module)), () => resolve()); + }); +} + const widgetResolveFns = {}; const widgetPromises = {}; @@ -115,6 +148,9 @@ async function solaraInit(mountId, appName) { console.log('solara init', mountId, appName); define("vue", [], () => Vue); define("vuetify", [], () => Vuetify); + if (typeof vuetifyPlugin !== "undefined") { + define("solara-vuetify-plugin", [], () => ({ vuetifyPlugin })); + } cookies = getCookiesMap(document.cookie); const searchParams = new URLSearchParams(window.location.search); let kernelId = searchParams.get('kernelid') || generateUuid() diff --git a/tests/integration/popout_external_widget_test.py b/tests/integration/popout_external_widget_test.py new file mode 100644 index 000000000..c657128b6 --- /dev/null +++ b/tests/integration/popout_external_widget_test.py @@ -0,0 +1,186 @@ +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import ipyvue +import ipywidgets as widgets +import traitlets +from reacton.core import _RenderContext + +import solara +from solara.server import kernel_context + +if TYPE_CHECKING: + import playwright.sync_api + +HERE = Path(__file__).parent + + +ipyvue.register_component_from_string( + "external-split-widget", + """ + + + + + + """, +) + + +ipyvue.register_component_from_string( + "full-vue-only-widget", + """ + + + + """, +) + + +class ExternalLibraryWidget(ipyvue.VueTemplate): + template = """ + + """ + + child = traitlets.Any().tag(sync=True, **widgets.widget_serialization) + + +class JupyterWidgetOnly(ipyvue.VueTemplate): + template = """ + + """ + + child = traitlets.Any().tag(sync=True, **widgets.widget_serialization) + + +class FullVueOnlyWidget(ipyvue.VueTemplate): + template = """ + + """ + + +@solara.component +def ExternalWidgetApp(): + child = solara.use_memo(lambda: widgets.Button(description="Nested widget works"), []) + return ExternalLibraryWidget.element(child=child) + + +@solara.component +def JupyterWidgetOnlyApp(): + child = solara.use_memo(lambda: widgets.Button(description="Plain jupyter-widget works"), []) + return JupyterWidgetOnly.element(child=child) + + +@solara.component +def FullVueOnlyApp(): + return FullVueOnlyWidget.element() + + +external_widget_app = ExternalWidgetApp() +jupyter_widget_only_app = JupyterWidgetOnlyApp() +full_vue_only_app = FullVueOnlyApp() + + +def test_popout_external_library_jupyter_widget(page_session: "playwright.sync_api.Page", solara_server, solara_app, extra_include_path): + with extra_include_path(HERE), solara_app("popout_external_widget_test:ExternalWidgetApp"): + page_session.goto(solara_server.base_url + "?solara-no-close-beacon") + page_session.locator(".external-split-widget").wait_for() + page_session.locator("text=Nested widget works").wait_for() + + contexts = list(kernel_context.contexts.values()) + assert len(contexts) == 1 + context = contexts[0] + kernel_id = context.id + rc = cast(_RenderContext, context.app_object) + widget = rc.find(ExternalLibraryWidget).widget + model_id = widget._model_id + + page_session.goto(solara_server.base_url + f"?kernelid={kernel_id}&modelid={model_id}") + page_session.locator(".external-split-widget").wait_for() + page_session.locator("text=External Vue library pane").wait_for() + page_session.locator("text=Nested widget works").wait_for() + + +def test_popout_jupyter_widget_only(page_session: "playwright.sync_api.Page", solara_server, solara_app, extra_include_path): + with extra_include_path(HERE), solara_app("popout_external_widget_test:JupyterWidgetOnlyApp"): + page_session.goto(solara_server.base_url + "?solara-no-close-beacon") + page_session.locator(".jupyter-widget-only").wait_for() + page_session.locator("text=Plain jupyter-widget works").wait_for() + + contexts = list(kernel_context.contexts.values()) + assert len(contexts) == 1 + context = contexts[0] + kernel_id = context.id + rc = cast(_RenderContext, context.app_object) + widget = rc.find(JupyterWidgetOnly).widget + model_id = widget._model_id + + page_session.goto(solara_server.base_url + f"?kernelid={kernel_id}&modelid={model_id}") + page_session.locator(".jupyter-widget-only").wait_for() + page_session.locator("text=Plain jupyter-widget works").wait_for() + + +def test_popout_full_vue_only(page_session: "playwright.sync_api.Page", solara_server, solara_app, extra_include_path): + with extra_include_path(HERE), solara_app("popout_external_widget_test:FullVueOnlyApp"): + page_session.goto(solara_server.base_url + "?solara-no-close-beacon") + page_session.locator(".full-vue-only-widget").wait_for() + page_session.locator("text=Full Vue component works").wait_for() + + contexts = list(kernel_context.contexts.values()) + assert len(contexts) == 1 + context = contexts[0] + kernel_id = context.id + rc = cast(_RenderContext, context.app_object) + widget = rc.find(FullVueOnlyWidget).widget + model_id = widget._model_id + + page_session.goto(solara_server.base_url + f"?kernelid={kernel_id}&modelid={model_id}") + page_session.locator(".full-vue-only-widget").wait_for() + page_session.locator("text=Full Vue component works").wait_for()