Allow global commands to be invoked from the command palette via URL (#6973)
This commit is contained in:
@ -399,7 +399,6 @@ test.describe('Command bar tests', () => {
|
|||||||
sortBy: 'last-modified-desc',
|
sortBy: 'last-modified-desc',
|
||||||
})
|
})
|
||||||
await page.goto(page.url() + targetURL)
|
await page.goto(page.url() + targetURL)
|
||||||
expect(page.url()).toContain(targetURL)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await test.step(`Submit the command`, async () => {
|
await test.step(`Submit the command`, async () => {
|
||||||
@ -463,7 +462,6 @@ test.describe('Command bar tests', () => {
|
|||||||
sortBy: 'last-modified-desc',
|
sortBy: 'last-modified-desc',
|
||||||
})
|
})
|
||||||
await page.goto(page.url() + targetURL)
|
await page.goto(page.url() + targetURL)
|
||||||
expect(page.url()).toContain(targetURL)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await test.step(`Submit the command`, async () => {
|
await test.step(`Submit the command`, async () => {
|
||||||
@ -661,4 +659,27 @@ c = 3 + a`
|
|||||||
`a = 5b = a * amyParameter001 = ${newValue}c = 3 + a`
|
`a = 5b = a * amyParameter001 = ${newValue}c = 3 + a`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Command palette can be opened via query parameter', async ({
|
||||||
|
page,
|
||||||
|
homePage,
|
||||||
|
cmdBar,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`${page.url()}/?cmd=app.theme&groupId=settings`)
|
||||||
|
await homePage.expectState({
|
||||||
|
projectCards: [],
|
||||||
|
sortBy: 'last-modified-desc',
|
||||||
|
})
|
||||||
|
await cmdBar.expectState({
|
||||||
|
stage: 'arguments',
|
||||||
|
commandName: 'Settings · app · theme',
|
||||||
|
currentArgKey: 'value',
|
||||||
|
currentArgValue: '',
|
||||||
|
headerArguments: {
|
||||||
|
Level: 'user',
|
||||||
|
Value: '',
|
||||||
|
},
|
||||||
|
highlightedHeaderArg: 'value',
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
16
src/App.tsx
16
src/App.tsx
@ -17,7 +17,7 @@ import { useLspContext } from '@src/components/LspProvider'
|
|||||||
import { ModelingSidebar } from '@src/components/ModelingSidebar/ModelingSidebar'
|
import { ModelingSidebar } from '@src/components/ModelingSidebar/ModelingSidebar'
|
||||||
import { UnitsMenu } from '@src/components/UnitsMenu'
|
import { UnitsMenu } from '@src/components/UnitsMenu'
|
||||||
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
|
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
|
||||||
import { useCreateFileLinkQuery } from '@src/hooks/useCreateFileLinkQueryWatcher'
|
import { useQueryParamEffects } from '@src/hooks/useQueryParamEffects'
|
||||||
import { useEngineConnectionSubscriptions } from '@src/hooks/useEngineConnectionSubscriptions'
|
import { useEngineConnectionSubscriptions } from '@src/hooks/useEngineConnectionSubscriptions'
|
||||||
import { useHotKeyListener } from '@src/hooks/useHotKeyListener'
|
import { useHotKeyListener } from '@src/hooks/useHotKeyListener'
|
||||||
import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
|
import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
|
||||||
@ -33,7 +33,6 @@ import {
|
|||||||
import { maybeWriteToDisk } from '@src/lib/telemetry'
|
import { maybeWriteToDisk } from '@src/lib/telemetry'
|
||||||
import type { IndexLoaderData } from '@src/lib/types'
|
import type { IndexLoaderData } from '@src/lib/types'
|
||||||
import { engineStreamActor, useSettings, useToken } from '@src/lib/singletons'
|
import { engineStreamActor, useSettings, useToken } from '@src/lib/singletons'
|
||||||
import { commandBarActor } from '@src/lib/singletons'
|
|
||||||
import { EngineStreamTransition } from '@src/machines/engineStreamMachine'
|
import { EngineStreamTransition } from '@src/machines/engineStreamMachine'
|
||||||
import { BillingTransition } from '@src/machines/billingMachine'
|
import { BillingTransition } from '@src/machines/billingMachine'
|
||||||
import { CommandBarOpenButton } from '@src/components/CommandBarOpenButton'
|
import { CommandBarOpenButton } from '@src/components/CommandBarOpenButton'
|
||||||
@ -62,21 +61,10 @@ maybeWriteToDisk()
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
useQueryParamEffects()
|
||||||
const { project, file } = useLoaderData() as IndexLoaderData
|
const { project, file } = useLoaderData() as IndexLoaderData
|
||||||
const [nativeFileMenuCreated, setNativeFileMenuCreated] = useState(false)
|
const [nativeFileMenuCreated, setNativeFileMenuCreated] = useState(false)
|
||||||
|
|
||||||
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
|
|
||||||
useCreateFileLinkQuery((argDefaultValues) => {
|
|
||||||
commandBarActor.send({
|
|
||||||
type: 'Find and select command',
|
|
||||||
data: {
|
|
||||||
groupId: 'projects',
|
|
||||||
name: 'Import file from URL',
|
|
||||||
argDefaultValues,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const filePath = useAbsoluteFilePath()
|
const filePath = useAbsoluteFilePath()
|
||||||
|
@ -32,6 +32,7 @@ export const CommandBar = () => {
|
|||||||
: Dialog
|
: Dialog
|
||||||
|
|
||||||
// Close the command bar when navigating
|
// Close the command bar when navigating
|
||||||
|
// but importantly not when the query parameters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (commandBarState.matches('Closed')) return
|
if (commandBarState.matches('Closed')) return
|
||||||
commandBarActor.send({ type: 'Close' })
|
commandBarActor.send({ type: 'Close' })
|
||||||
|
@ -1,66 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { useLocation, useSearchParams } from 'react-router-dom'
|
|
||||||
|
|
||||||
import { useNetworkContext } from '@src/hooks/useNetworkContext'
|
|
||||||
import { EngineConnectionStateType } from '@src/lang/std/engineConnection'
|
|
||||||
import { base64ToString } from '@src/lib/base64'
|
|
||||||
import type { ProjectsCommandSchema } from '@src/lib/commandBarConfigs/projectsCommandConfig'
|
|
||||||
import {
|
|
||||||
CREATE_FILE_URL_PARAM,
|
|
||||||
DEFAULT_FILE_NAME,
|
|
||||||
PROJECT_ENTRYPOINT,
|
|
||||||
} from '@src/lib/constants'
|
|
||||||
import { isDesktop } from '@src/lib/isDesktop'
|
|
||||||
import type { FileLinkParams } from '@src/lib/links'
|
|
||||||
import { PATHS } from '@src/lib/paths'
|
|
||||||
|
|
||||||
// For initializing the command arguments, we actually want `method` to be undefined
|
|
||||||
// so that we don't skip it in the command palette.
|
|
||||||
export type CreateFileSchemaMethodOptional = Omit<
|
|
||||||
ProjectsCommandSchema['Import file from URL'],
|
|
||||||
'method'
|
|
||||||
> & {
|
|
||||||
method?: 'newProject' | 'existingProject'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* companion to createFileLink. This hook runs an effect on mount that
|
|
||||||
* checks the URL for the CREATE_FILE_URL_PARAM and triggers the "Create file"
|
|
||||||
* command if it is present, loading the command's default values from the other
|
|
||||||
* URL parameters.
|
|
||||||
*/
|
|
||||||
export function useCreateFileLinkQuery(
|
|
||||||
callback: (args: CreateFileSchemaMethodOptional) => void
|
|
||||||
) {
|
|
||||||
const { immediateState } = useNetworkContext()
|
|
||||||
const { pathname } = useLocation()
|
|
||||||
const [searchParams] = useSearchParams()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isHome = pathname === PATHS.HOME
|
|
||||||
const createFileParam = searchParams.has(CREATE_FILE_URL_PARAM)
|
|
||||||
|
|
||||||
if (
|
|
||||||
createFileParam &&
|
|
||||||
(immediateState.type ===
|
|
||||||
EngineConnectionStateType.ConnectionEstablished ||
|
|
||||||
isHome)
|
|
||||||
) {
|
|
||||||
const params: FileLinkParams = {
|
|
||||||
code: base64ToString(
|
|
||||||
decodeURIComponent(searchParams.get('code') ?? '')
|
|
||||||
),
|
|
||||||
name: searchParams.get('name') ?? DEFAULT_FILE_NAME,
|
|
||||||
isRestrictedToOrg: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const argDefaultValues: CreateFileSchemaMethodOptional = {
|
|
||||||
name: PROJECT_ENTRYPOINT,
|
|
||||||
code: params.code || '',
|
|
||||||
method: isDesktop() ? undefined : 'existingProject',
|
|
||||||
}
|
|
||||||
|
|
||||||
callback(argDefaultValues)
|
|
||||||
}
|
|
||||||
}, [searchParams, immediateState])
|
|
||||||
}
|
|
153
src/hooks/useQueryParamEffects.ts
Normal file
153
src/hooks/useQueryParamEffects.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { base64ToString } from '@src/lib/base64'
|
||||||
|
import type { ProjectsCommandSchema } from '@src/lib/commandBarConfigs/projectsCommandConfig'
|
||||||
|
import {
|
||||||
|
CMD_GROUP_QUERY_PARAM,
|
||||||
|
CMD_NAME_QUERY_PARAM,
|
||||||
|
CREATE_FILE_URL_PARAM,
|
||||||
|
DEFAULT_FILE_NAME,
|
||||||
|
POOL_QUERY_PARAM,
|
||||||
|
PROJECT_ENTRYPOINT,
|
||||||
|
} from '@src/lib/constants'
|
||||||
|
import { isDesktop } from '@src/lib/isDesktop'
|
||||||
|
import type { FileLinkParams } from '@src/lib/links'
|
||||||
|
import { commandBarActor } from '@src/lib/singletons'
|
||||||
|
|
||||||
|
// For initializing the command arguments, we actually want `method` to be undefined
|
||||||
|
// so that we don't skip it in the command palette.
|
||||||
|
export type CreateFileSchemaMethodOptional = Omit<
|
||||||
|
ProjectsCommandSchema['Import file from URL'],
|
||||||
|
'method'
|
||||||
|
> & {
|
||||||
|
method?: 'newProject' | 'existingProject'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of hooks that watch for query parameters and dispatch a callback.
|
||||||
|
* Currently watches for:
|
||||||
|
* `?createFile`
|
||||||
|
* "?cmd=<some-command-name>&groupId=<some-group-id>"
|
||||||
|
*/
|
||||||
|
export function useQueryParamEffects() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const shouldInvokeCreateFile = searchParams.has(CREATE_FILE_URL_PARAM)
|
||||||
|
const shouldInvokeGenericCmd =
|
||||||
|
searchParams.has(CMD_NAME_QUERY_PARAM) &&
|
||||||
|
searchParams.has(CMD_GROUP_QUERY_PARAM)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches for legacy `?create-file` hook, which share links currently use.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldInvokeCreateFile) {
|
||||||
|
const argDefaultValues = buildCreateFileCommandArgs(searchParams)
|
||||||
|
commandBarActor.send({
|
||||||
|
type: 'Find and select command',
|
||||||
|
data: {
|
||||||
|
groupId: 'projects',
|
||||||
|
name: 'Import file from URL',
|
||||||
|
argDefaultValues,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete the query param after the command has been invoked.
|
||||||
|
searchParams.delete(CREATE_FILE_URL_PARAM)
|
||||||
|
setSearchParams(searchParams)
|
||||||
|
}
|
||||||
|
}, [shouldInvokeCreateFile, setSearchParams])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic commands are triggered by query parameters
|
||||||
|
* with the pattern: `?cmd=<command-name>&groupId=<group-id>`
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldInvokeGenericCmd) {
|
||||||
|
const commandData = buildGenericCommandArgs(searchParams)
|
||||||
|
if (!commandData) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commandBarActor.send({
|
||||||
|
type: 'Find and select command',
|
||||||
|
data: commandData,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete all the query parameters that aren't reserved
|
||||||
|
searchParams.delete(CMD_NAME_QUERY_PARAM)
|
||||||
|
searchParams.delete(CMD_GROUP_QUERY_PARAM)
|
||||||
|
const keysToDelete = searchParams
|
||||||
|
.entries()
|
||||||
|
.toArray()
|
||||||
|
// Filter out known keys
|
||||||
|
.filter(([key]) => {
|
||||||
|
const reservedKeys = [
|
||||||
|
CMD_NAME_QUERY_PARAM,
|
||||||
|
CMD_GROUP_QUERY_PARAM,
|
||||||
|
CREATE_FILE_URL_PARAM,
|
||||||
|
POOL_QUERY_PARAM,
|
||||||
|
]
|
||||||
|
|
||||||
|
return !reservedKeys.includes(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [key] of keysToDelete) {
|
||||||
|
searchParams.delete(key)
|
||||||
|
}
|
||||||
|
setSearchParams(searchParams)
|
||||||
|
}
|
||||||
|
}, [shouldInvokeGenericCmd, setSearchParams])
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreateFileCommandArgs(searchParams: URLSearchParams) {
|
||||||
|
const params: Omit<FileLinkParams, 'isRestrictedToOrg'> = {
|
||||||
|
code: base64ToString(decodeURIComponent(searchParams.get('code') ?? '')),
|
||||||
|
name: searchParams.get('name') ?? DEFAULT_FILE_NAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
const argDefaultValues: CreateFileSchemaMethodOptional = {
|
||||||
|
name: PROJECT_ENTRYPOINT,
|
||||||
|
code: params.code || '',
|
||||||
|
method: isDesktop() ? undefined : 'existingProject',
|
||||||
|
}
|
||||||
|
|
||||||
|
return argDefaultValues
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGenericCommandArgs(searchParams: URLSearchParams) {
|
||||||
|
// We have already verified these exist before calling
|
||||||
|
const name = searchParams.get('cmd')
|
||||||
|
const groupId = searchParams.get('groupId')
|
||||||
|
|
||||||
|
if (!name || !groupId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredParams = searchParams
|
||||||
|
.entries()
|
||||||
|
// Filter out known keys
|
||||||
|
.filter(
|
||||||
|
([key]) =>
|
||||||
|
[
|
||||||
|
CMD_NAME_QUERY_PARAM,
|
||||||
|
CMD_GROUP_QUERY_PARAM,
|
||||||
|
CREATE_FILE_URL_PARAM,
|
||||||
|
POOL_QUERY_PARAM,
|
||||||
|
].indexOf(key) === -1
|
||||||
|
)
|
||||||
|
const argDefaultValues = filteredParams.reduce(
|
||||||
|
(acc, [key, value]) => {
|
||||||
|
const decodedKey = decodeURIComponent(key)
|
||||||
|
const decodedValue = decodeURIComponent(value)
|
||||||
|
acc[decodedKey] = decodedValue
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
groupId,
|
||||||
|
argDefaultValues,
|
||||||
|
}
|
||||||
|
}
|
@ -118,9 +118,6 @@ export const MAKE_TOAST_MESSAGES = {
|
|||||||
/** The URL for the KCL samples manifest files */
|
/** The URL for the KCL samples manifest files */
|
||||||
export const KCL_SAMPLES_MANIFEST_URL = '/kcl-samples/manifest.json'
|
export const KCL_SAMPLES_MANIFEST_URL = '/kcl-samples/manifest.json'
|
||||||
|
|
||||||
/** URL parameter to create a file */
|
|
||||||
export const CREATE_FILE_URL_PARAM = 'create-file'
|
|
||||||
|
|
||||||
/** Toast id for the app auto-updater toast */
|
/** Toast id for the app auto-updater toast */
|
||||||
export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast'
|
export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast'
|
||||||
|
|
||||||
@ -206,3 +203,14 @@ export const ML_EXPERIMENTAL_MESSAGE = 'This feature is experimental.'
|
|||||||
* while in the onboarding flow.
|
* while in the onboarding flow.
|
||||||
*/
|
*/
|
||||||
export const ONBOARDING_DATA_ATTRIBUTE = 'onboarding-id'
|
export const ONBOARDING_DATA_ATTRIBUTE = 'onboarding-id'
|
||||||
|
|
||||||
|
/** A query parameter that invokes a command */
|
||||||
|
export const CMD_NAME_QUERY_PARAM = 'cmd'
|
||||||
|
/** A query parameter that invokes a command */
|
||||||
|
export const CMD_GROUP_QUERY_PARAM = 'groupId'
|
||||||
|
/** A query parameter that manually sets the engine pool the frontend should use. */
|
||||||
|
export const POOL_QUERY_PARAM = 'pool'
|
||||||
|
/** A query parameter to create a file
|
||||||
|
* @deprecated: supporting old share links with this. For new command URLs, use "cmd"
|
||||||
|
*/
|
||||||
|
export const CREATE_FILE_URL_PARAM = 'create-file'
|
||||||
|
@ -229,7 +229,7 @@ export const commandBarMachine = setup({
|
|||||||
cmd.name === event.data.name && cmd.groupId === event.data.groupId
|
cmd.name === event.data.name && cmd.groupId === event.data.groupId
|
||||||
)
|
)
|
||||||
|
|
||||||
return !!found ? found : context.selectedCommand
|
return found || context.selectedCommand
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
'Initialize arguments to submit': assign({
|
'Initialize arguments to submit': assign({
|
||||||
|
@ -19,7 +19,7 @@ import {
|
|||||||
useProjectSearch,
|
useProjectSearch,
|
||||||
} from '@src/components/ProjectSearchBar'
|
} from '@src/components/ProjectSearchBar'
|
||||||
import { BillingDialog } from '@src/components/BillingDialog'
|
import { BillingDialog } from '@src/components/BillingDialog'
|
||||||
import { useCreateFileLinkQuery } from '@src/hooks/useCreateFileLinkQueryWatcher'
|
import { useQueryParamEffects } from '@src/hooks/useQueryParamEffects'
|
||||||
import { useMenuListener } from '@src/hooks/useMenu'
|
import { useMenuListener } from '@src/hooks/useMenu'
|
||||||
import { isDesktop } from '@src/lib/isDesktop'
|
import { isDesktop } from '@src/lib/isDesktop'
|
||||||
import { PATHS } from '@src/lib/paths'
|
import { PATHS } from '@src/lib/paths'
|
||||||
@ -70,6 +70,7 @@ type ReadWriteProjectState = {
|
|||||||
// This route only opens in the desktop context for now,
|
// This route only opens in the desktop context for now,
|
||||||
// as defined in Router.tsx, so we can use the desktop APIs and types.
|
// as defined in Router.tsx, so we can use the desktop APIs and types.
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
|
useQueryParamEffects()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const readWriteProjectDir = useCanReadWriteProjectDirectory()
|
const readWriteProjectDir = useCanReadWriteProjectDirectory()
|
||||||
const [nativeFileMenuCreated, setNativeFileMenuCreated] = useState(false)
|
const [nativeFileMenuCreated, setNativeFileMenuCreated] = useState(false)
|
||||||
@ -88,18 +89,6 @@ const Home = () => {
|
|||||||
billingActor.send({ type: BillingTransition.Update, apiToken })
|
billingActor.send({ type: BillingTransition.Update, apiToken })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
|
|
||||||
useCreateFileLinkQuery((argDefaultValues) => {
|
|
||||||
commandBarActor.send({
|
|
||||||
type: 'Find and select command',
|
|
||||||
data: {
|
|
||||||
groupId: 'projects',
|
|
||||||
name: 'Import file from URL',
|
|
||||||
argDefaultValues,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const onboardingStatus = settings.app.onboardingStatus.current
|
const onboardingStatus = settings.app.onboardingStatus.current
|
||||||
|
Reference in New Issue
Block a user