From 53f51338aff2131beaf0cc391e49aafc613ec30b Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Tue, 4 Feb 2025 16:33:50 +0000 Subject: [PATCH 1/6] wip: add awesomebar-like functionality --- packages/frontmatter/package.json | 1 - packages/frontmatter/src/LaunchButton.tsx | 339 ++++++++++++++-------- 2 files changed, 211 insertions(+), 129 deletions(-) diff --git a/packages/frontmatter/package.json b/packages/frontmatter/package.json index e383f85d..96e0aa01 100644 --- a/packages/frontmatter/package.json +++ b/packages/frontmatter/package.json @@ -25,7 +25,6 @@ "@radix-ui/react-form": "^0.1.0", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.2", - "@radix-ui/react-tabs": "^1.1.1", "@scienceicons/react": "^0.0.6", "classnames": "^2.3.2", "myst-common": "*", diff --git a/packages/frontmatter/src/LaunchButton.tsx b/packages/frontmatter/src/LaunchButton.tsx index 707f493f..1fa461c5 100644 --- a/packages/frontmatter/src/LaunchButton.tsx +++ b/packages/frontmatter/src/LaunchButton.tsx @@ -2,8 +2,16 @@ import React, { useRef, useCallback, useState } from 'react'; import classNames from 'classnames'; import * as Popover from '@radix-ui/react-popover'; -import { RocketIcon, Cross2Icon, ClipboardCopyIcon, ExternalLinkIcon } from '@radix-ui/react-icons'; -import * as Tabs from '@radix-ui/react-tabs'; +import { + RocketIcon, + Cross2Icon, + ClipboardCopyIcon, + ExternalLinkIcon, + QuestionMarkCircledIcon, + UpdateIcon, + Link2Icon, +} from '@radix-ui/react-icons'; +import { JupyterIcon } from '@scienceicons/react/24/solid'; import * as Form from '@radix-ui/react-form'; import type { ExpandedThebeFrontmatter, BinderHubOptions } from 'myst-frontmatter'; @@ -229,91 +237,77 @@ function makeNbgitpullerURL(options: BinderHubOptions, location: string): string return `git-pull?${query}`; } -function BinderLaunchContent(props: ModalLaunchProps) { - const { thebe, location, onLaunch } = props; - const { binder } = thebe; - const defaultBinderBaseURL = binder?.url ?? 'https://mybinder.org'; - - const formRef = useRef(null); - const buildLink = useCallback(() => { - const form = formRef.current; - if (!form) { - return; - } +function useDebounce(value: T, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + React.useEffect(() => { + const handle = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handle); + }; + }, [value, delay]); + return debouncedValue; +} - const data = Object.fromEntries(new FormData(form) as any); - return makeBinderURL({ ...(binder ?? {}), url: data.url || defaultBinderBaseURL }, location); - }, [formRef, location, binder]); +type ProviderType = 'binderhub' | 'jupyterhub'; - // FIXME: use ValidityState from radix-ui once passing-by-name is fixed - const urlRef = useRef(null); - const buildValidLink = useCallback(() => { - if (urlRef.current?.dataset.invalid === 'true') { - return; - } else { - return buildLink(); +/** + * Interrogate a possible remote provider URL to + * determine whether it is a BinderHub or JupyterHub + * + * @param baseUrl - URL to interrogate, ending with a slash + */ +async function interrogateProviderType(baseUrl: string): Promise { + const binderURL = `${baseUrl}versions`; + try { + const response = await fetch(binderURL); + const data = await response.json(); + if ('binderhub' in data) { + return 'binderhub'; } - }, [buildLink, urlRef]); - - const handleSubmit = useCallback( - (event: React.SyntheticEvent) => { - event.preventDefault(); - - const link = buildLink(); + } catch (err) { + console.debug(`Couldn't reach ${binderURL}`); + } + const hubURL = `${baseUrl}hub/api/`; + try { + const response = await fetch(hubURL); + const data = await response.json(); + if ('version' in data) { + return 'jupyterhub'; + } + } catch (err) { + console.debug(`Couldn't reach ${binderURL}`); + } - // Link should exist, but guard anyway - if (link) { - window?.open(link, '_blank')?.focus(); - } - onLaunch?.(); - }, - [defaultBinderBaseURL, buildLink, onLaunch], - ); - return ( - -

- Launch on a BinderHub e.g. mybinder.org -

- -
- BinderHub URL - - Please provide a valid URL that starts with http(s). - -
- - - -
-
- - - - -
-
- ); + return 'error'; } -function JupyterHubLaunchContent(props: ModalLaunchProps) { - const { onLaunch, location, thebe } = props; +function DetectLaunchContent(props: ModalLaunchProps) { + const { thebe, location, onLaunch } = props; const { binder } = thebe; + const defaultBinderBaseURL = binder?.url ?? 'https://mybinder.org'; - const defaultHubBaseURL = ''; + // Detect the provider type + const [detectedProviderType, setDetectedProviderType] = useState< + ProviderType | 'error' | undefined + >(undefined); + + // Handle URL entry that needs to be debounced + const [url, setURL] = useState(''); + const onUrlChanged = useCallback( + (event: React.ChangeEvent) => { + // Reset the known state of the provider + setDetectedProviderType(undefined); + // Update the recorded state of the URL input + setURL(event.target.value); + }, + [setURL], + ); const formRef = useRef(null); + const buildLink = useCallback(() => { const form = formRef.current; if (!form) { @@ -321,14 +315,27 @@ function JupyterHubLaunchContent(props: ModalLaunchProps) { } const data = Object.fromEntries(new FormData(form) as any); - const rawHubBaseURL = data.url; - if (!rawHubBaseURL) { + const rawBaseUrl = data.url; + if (!rawBaseUrl) { return; } - const gitPullURL = makeNbgitpullerURL(binder ?? {}, location); - const hubURL = ensureBasename(rawHubBaseURL); - return `${hubURL}hub/user-redirect/${gitPullURL}`; - }, [formRef, location, binder]); + const baseUrl = ensureBasename(rawBaseUrl); + + const userProvider = data?.provider as ProviderType | undefined; + const provider = userProvider ?? detectedProviderType; + switch (provider) { + case 'jupyterhub': { + const gitPullURL = makeNbgitpullerURL(binder ?? {}, location); + return `${baseUrl}hub/user-redirect/${gitPullURL}`; + } + case 'binderhub': { + return makeBinderURL({ ...(binder ?? {}), url: baseUrl || defaultBinderBaseURL }, location); + } + case undefined: { + return; + } + } + }, [formRef, location, binder, detectedProviderType]); // FIXME: use ValidityState from radix-ui once passing-by-name is fixed const urlRef = useRef(null); @@ -340,6 +347,44 @@ function JupyterHubLaunchContent(props: ModalLaunchProps) { } }, [buildLink, urlRef]); + // Detect the provider type on debounced text input + const debouncedURL = useDebounce(url, 100); + const [isInterrogating, setIsInterrogating] = useState(false); + React.useEffect(() => { + // Check validity manually to ensure that we don't make requests that aren't sensible + const urlIsValid = !!urlRef.current?.checkValidity?.(); + // Don't detect URL if it isn't valid + if (!urlIsValid) { + return; + } + + // Enter interrogating state + setIsInterrogating(true); + + // Interrogate remote endpoint + let baseName; + try { + baseName = ensureBasename(debouncedURL); + } catch (err) { + return; + } + interrogateProviderType(baseName) + .then((provider: ProviderType | 'error') => { + if (provider !== 'error') { + setDetectedProviderType(provider); + } else if (baseName.includes('binder')) { + setDetectedProviderType('binderhub'); + } else if (baseName.includes('showcase')) { + setDetectedProviderType('jupyterhub'); + } else { + setDetectedProviderType('error'); + } + }) + .catch(console.error) + // Clear the interrogating state + .finally(() => setIsInterrogating(false)); + }, [debouncedURL, urlRef, setIsInterrogating]); + const handleSubmit = useCallback( (event: React.SyntheticEvent) => { event.preventDefault(); @@ -352,46 +397,108 @@ function JupyterHubLaunchContent(props: ModalLaunchProps) { } onLaunch?.(); }, - [defaultHubBaseURL, buildLink, onLaunch], + [defaultBinderBaseURL, buildLink, onLaunch], ); return ( - -

Launch on a JupyterHub

+ -
- JupyterHub URL - - Please provide a URL. - - +
+ + Enter a JupyterHub or BinderHub URL, e.g.{' '} + + https://mybinder.org + + Please provide a valid URL that starts with http(s).
- - - +
+ + {(detectedProviderType === 'binderhub' && ( + + )) || + (detectedProviderType === 'jupyterhub' && ( + + )) || + (detectedProviderType === 'error' && ( + + )) || + (isInterrogating && ( + + )) || ( + + )} + + + + +
-
+
+ + The provider type could not be detected automatically. what kind of provider have you + given? + +
+ + +
+
+ + +
+
+ +
+ -
+ ); } @@ -413,34 +520,10 @@ export function LaunchButton(props: LaunchProps) { - - - - Binder - - - JupyterHub - - - - - - - - - + Date: Tue, 4 Feb 2025 17:40:44 +0000 Subject: [PATCH 2/6] chore: add changeset --- .changeset/wild-ghosts-tease.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wild-ghosts-tease.md diff --git a/.changeset/wild-ghosts-tease.md b/.changeset/wild-ghosts-tease.md new file mode 100644 index 00000000..c931d003 --- /dev/null +++ b/.changeset/wild-ghosts-tease.md @@ -0,0 +1,5 @@ +--- +'@myst-theme/frontmatter': patch +--- + +Add awesomebar support to launch button From 8972f14d57c0b4ee2652c7e6f7a315ca103600e8 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Tue, 4 Feb 2025 17:49:27 +0000 Subject: [PATCH 3/6] refactor: update props docstrings --- packages/frontmatter/src/LaunchButton.tsx | 38 +++++++++++++++++------ 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/frontmatter/src/LaunchButton.tsx b/packages/frontmatter/src/LaunchButton.tsx index 1fa461c5..46c49137 100644 --- a/packages/frontmatter/src/LaunchButton.tsx +++ b/packages/frontmatter/src/LaunchButton.tsx @@ -24,32 +24,50 @@ const GIST_USERNAME_REPO_REGEX = type CopyButtonProps = { defaultMessage: string; - alternateMessage?: string; - timeout?: number; + copiedMessage?: string; + invalidLinkFallback?: string; + copiedMessageDuration?: number; buildLink: () => string | undefined; className?: string; }; +/** + * Component to add a copy-to-clipboard button + */ function CopyButton(props: CopyButtonProps) { - const { className, defaultMessage, alternateMessage, buildLink, timeout } = props; + const { + className, + defaultMessage, + copiedMessage, + invalidLinkFallback, + buildLink, + copiedMessageDuration, + } = props; const [message, setMessage] = useState(defaultMessage); const copyLink = useCallback(() => { - // Build the link for the clipboard - const link = props.buildLink(); // In secure links, we can copy it! if (window.isSecureContext) { + // Build the link for the clipboard + const link = props.buildLink(); // Write to clipboard - window.navigator.clipboard.writeText(link ?? ''); + window.navigator.clipboard.writeText(link ?? invalidLinkFallback ?? ''); // Update UI - setMessage(alternateMessage ?? defaultMessage); + setMessage(copiedMessage ?? defaultMessage); // Set callback to restore message setTimeout(() => { setMessage(defaultMessage); - }, timeout ?? 1000); + }, copiedMessageDuration ?? 1000); } - }, [defaultMessage, alternateMessage, buildLink, timeout, setMessage]); + }, [ + defaultMessage, + copiedMessage, + buildLink, + copiedMessageDuration, + invalidLinkFallback, + setMessage, + ]); return (