Get query-triggered command working in browser too
This commit is contained in:
@ -26,6 +26,7 @@ import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { project, file } = useLoaderData() as IndexLoaderData
|
const { project, file } = useLoaderData() as IndexLoaderData
|
||||||
|
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
|
||||||
useCreateFileLinkQuery()
|
useCreateFileLinkQuery()
|
||||||
useRefreshSettings(PATHS.FILE + 'SETTINGS')
|
useRefreshSettings(PATHS.FILE + 'SETTINGS')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
@ -4,6 +4,7 @@ import { useCommandsContext } from 'hooks/useCommandsContext'
|
|||||||
import { Command } from 'lib/commandTypes'
|
import { Command } from 'lib/commandTypes'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { CustomIcon } from './CustomIcon'
|
import { CustomIcon } from './CustomIcon'
|
||||||
|
import { getActorNextEvents } from 'lib/utils'
|
||||||
|
|
||||||
function CommandComboBox({
|
function CommandComboBox({
|
||||||
options,
|
options,
|
||||||
@ -73,7 +74,8 @@ function CommandComboBox({
|
|||||||
<Combobox.Option
|
<Combobox.Option
|
||||||
key={option.groupId + option.name + (option.displayName || '')}
|
key={option.groupId + option.name + (option.displayName || '')}
|
||||||
value={option}
|
value={option}
|
||||||
className="flex items-center gap-4 px-4 py-1.5 first:mt-2 last:mb-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90"
|
className="flex items-center gap-4 px-4 py-1.5 first:mt-2 last:mb-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90 ui-disabled:!text-chalkboard-50"
|
||||||
|
disabled={optionIsDisabled(option)}
|
||||||
>
|
>
|
||||||
{'icon' in option && option.icon && (
|
{'icon' in option && option.icon && (
|
||||||
<CustomIcon name={option.icon} className="w-5 h-5" />
|
<CustomIcon name={option.icon} className="w-5 h-5" />
|
||||||
@ -96,3 +98,11 @@ function CommandComboBox({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default CommandComboBox
|
export default CommandComboBox
|
||||||
|
|
||||||
|
function optionIsDisabled(option: Command): boolean {
|
||||||
|
return (
|
||||||
|
'machineActor' in option &&
|
||||||
|
option.machineActor !== undefined &&
|
||||||
|
!getActorNextEvents(option.machineActor.getSnapshot()).includes(option.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -186,7 +186,6 @@ function ProjectMenuPopover({
|
|||||||
{
|
{
|
||||||
id: 'share-link',
|
id: 'share-link',
|
||||||
Element: 'button',
|
Element: 'button',
|
||||||
className: !isDesktop() ? 'hidden' : '',
|
|
||||||
children: 'Share link to file',
|
children: 'Share link to file',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const shareUrl = createFileLink({
|
const shareUrl = createFileLink({
|
||||||
|
@ -3,11 +3,11 @@ import { useCommandsContext } from 'hooks/useCommandsContext'
|
|||||||
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
|
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
|
||||||
import { useProjectsLoader } from 'hooks/useProjectsLoader'
|
import { useProjectsLoader } from 'hooks/useProjectsLoader'
|
||||||
import { projectsMachine } from 'machines/projectsMachine'
|
import { projectsMachine } from 'machines/projectsMachine'
|
||||||
import { createContext, useEffect, useState } from 'react'
|
import { createContext, useCallback, useEffect, useState } from 'react'
|
||||||
import { Actor, AnyStateMachine, fromPromise, Prop, StateFrom } from 'xstate'
|
import { Actor, AnyStateMachine, fromPromise, Prop, StateFrom } from 'xstate'
|
||||||
import { useLspContext } from './LspProvider'
|
import { useLspContext } from './LspProvider'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { PATHS } from 'lib/paths'
|
import { PATHS } from 'lib/paths'
|
||||||
import {
|
import {
|
||||||
createNewProjectDirectory,
|
createNewProjectDirectory,
|
||||||
@ -18,11 +18,27 @@ import {
|
|||||||
getNextProjectIndex,
|
getNextProjectIndex,
|
||||||
interpolateProjectNameWithIndex,
|
interpolateProjectNameWithIndex,
|
||||||
doesProjectNameNeedInterpolated,
|
doesProjectNameNeedInterpolated,
|
||||||
|
getNextFileName,
|
||||||
} from 'lib/desktopFS'
|
} from 'lib/desktopFS'
|
||||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
import useStateMachineCommands from 'hooks/useStateMachineCommands'
|
import useStateMachineCommands from 'hooks/useStateMachineCommands'
|
||||||
import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig'
|
import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig'
|
||||||
import { isDesktop } from 'lib/isDesktop'
|
import { isDesktop } from 'lib/isDesktop'
|
||||||
|
import {
|
||||||
|
CREATE_FILE_URL_PARAM,
|
||||||
|
FILE_EXT,
|
||||||
|
PROJECT_ENTRYPOINT,
|
||||||
|
} from 'lib/constants'
|
||||||
|
import { DeepPartial } from 'lib/types'
|
||||||
|
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
|
||||||
|
import { codeManager } from 'lib/singletons'
|
||||||
|
import {
|
||||||
|
loadAndValidateSettings,
|
||||||
|
projectConfigurationToSettingsPayload,
|
||||||
|
saveSettings,
|
||||||
|
setSettingsAtLevel,
|
||||||
|
} from 'lib/settings/settingsUtils'
|
||||||
|
import { Project } from 'lib/project'
|
||||||
|
|
||||||
type MachineContext<T extends AnyStateMachine> = {
|
type MachineContext<T extends AnyStateMachine> = {
|
||||||
state?: StateFrom<T>
|
state?: StateFrom<T>
|
||||||
@ -44,47 +60,25 @@ export const ProjectsContextProvider = ({
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) => {
|
|
||||||
return isDesktop() ? (
|
|
||||||
<ProjectsContextDesktop>{children}</ProjectsContextDesktop>
|
|
||||||
) : (
|
|
||||||
<ProjectsContextWeb>{children}</ProjectsContextWeb>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<ProjectsMachineContext.Provider
|
|
||||||
value={{
|
|
||||||
state: undefined,
|
|
||||||
send: () => {},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ProjectsMachineContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectsContextDesktop = ({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const clearImportSearchParams = useCallback(() => {
|
||||||
|
// Clear the search parameters related to the "Import file from URL" command
|
||||||
|
// or we'll never be able cancel or submit it.
|
||||||
|
searchParams.delete(CREATE_FILE_URL_PARAM)
|
||||||
|
searchParams.delete('code')
|
||||||
|
searchParams.delete('name')
|
||||||
|
searchParams.delete('units')
|
||||||
|
setSearchParams(searchParams)
|
||||||
|
}, [searchParams, setSearchParams])
|
||||||
const { commandBarSend } = useCommandsContext()
|
const { commandBarSend } = useCommandsContext()
|
||||||
const { onProjectOpen } = useLspContext()
|
const { onProjectOpen } = useLspContext()
|
||||||
const {
|
const {
|
||||||
settings: { context: settings },
|
settings: { context: settings, send: settingsSend },
|
||||||
} = useSettingsAuthContext()
|
} = useSettingsAuthContext()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(
|
|
||||||
'project directory changed',
|
|
||||||
settings.app.projectDirectory.current
|
|
||||||
)
|
|
||||||
}, [settings.app.projectDirectory.current])
|
|
||||||
|
|
||||||
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
|
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
|
||||||
const { projectPaths, projectsDir } = useProjectsLoader([
|
const { projectPaths, projectsDir } = useProjectsLoader([
|
||||||
projectsLoaderTrigger,
|
projectsLoaderTrigger,
|
||||||
@ -163,6 +157,31 @@ const ProjectsContextDesktop = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
navigateToFile: ({ context, event }) => {
|
||||||
|
if (event.type !== 'xstate.done.actor.create-file') return
|
||||||
|
// For now, the browser version of create-file doesn't need to navigate
|
||||||
|
// since it just overwrites the current file.
|
||||||
|
if (!isDesktop()) return
|
||||||
|
let projectPath = window.electron.join(
|
||||||
|
context.defaultDirectory,
|
||||||
|
event.output.projectName
|
||||||
|
)
|
||||||
|
let filePath = window.electron.join(
|
||||||
|
projectPath,
|
||||||
|
event.output.fileName
|
||||||
|
)
|
||||||
|
onProjectOpen(
|
||||||
|
{
|
||||||
|
name: event.output.projectName,
|
||||||
|
path: projectPath,
|
||||||
|
},
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const pathToNavigateTo = `${PATHS.FILE}/${encodeURIComponent(
|
||||||
|
filePath
|
||||||
|
)}`
|
||||||
|
navigate(pathToNavigateTo)
|
||||||
|
},
|
||||||
toastSuccess: ({ event }) =>
|
toastSuccess: ({ event }) =>
|
||||||
toast.success(
|
toast.success(
|
||||||
('data' in event && typeof event.data === 'string' && event.data) ||
|
('data' in event && typeof event.data === 'string' && event.data) ||
|
||||||
@ -182,7 +201,10 @@ const ProjectsContextDesktop = ({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
actors: {
|
actors: {
|
||||||
readProjects: fromPromise(() => listProjects()),
|
readProjects: fromPromise(async () => {
|
||||||
|
if (!isDesktop()) return [] as Project[]
|
||||||
|
return listProjects()
|
||||||
|
}),
|
||||||
createProject: fromPromise(async ({ input }) => {
|
createProject: fromPromise(async ({ input }) => {
|
||||||
let name = (
|
let name = (
|
||||||
input && 'name' in input && input.name
|
input && 'name' in input && input.name
|
||||||
@ -238,6 +260,101 @@ const ProjectsContextDesktop = ({
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
createFile: fromPromise(async ({ input }) => {
|
||||||
|
let projectName =
|
||||||
|
(input.method === 'newProject' ? input.name : input.projectName) ||
|
||||||
|
settings.projects.defaultProjectName.current
|
||||||
|
let fileName =
|
||||||
|
input.method === 'newProject'
|
||||||
|
? PROJECT_ENTRYPOINT
|
||||||
|
: input.name.endsWith(FILE_EXT)
|
||||||
|
? input.name
|
||||||
|
: input.name + FILE_EXT
|
||||||
|
let message = 'File created successfully'
|
||||||
|
const unitsConfiguration: DeepPartial<Configuration> = {
|
||||||
|
settings: {
|
||||||
|
project: {
|
||||||
|
directory: settings.app.projectDirectory.current,
|
||||||
|
},
|
||||||
|
modeling: {
|
||||||
|
base_unit: input.units,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDesktop()) {
|
||||||
|
const needsInterpolated =
|
||||||
|
doesProjectNameNeedInterpolated(projectName)
|
||||||
|
console.log(
|
||||||
|
`The project name "${projectName}" needs interpolated: ${needsInterpolated}`
|
||||||
|
)
|
||||||
|
if (needsInterpolated) {
|
||||||
|
const nextIndex = getNextProjectIndex(projectName, input.projects)
|
||||||
|
projectName = interpolateProjectNameWithIndex(
|
||||||
|
projectName,
|
||||||
|
nextIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the project around the file if newProject
|
||||||
|
if (input.method === 'newProject') {
|
||||||
|
await createNewProjectDirectory(
|
||||||
|
projectName,
|
||||||
|
input.code,
|
||||||
|
unitsConfiguration
|
||||||
|
)
|
||||||
|
message = `Project "${projectName}" created successfully with link contents`
|
||||||
|
} else {
|
||||||
|
let projectPath = window.electron.join(
|
||||||
|
settings.app.projectDirectory.current,
|
||||||
|
projectName
|
||||||
|
)
|
||||||
|
|
||||||
|
message = `File "${fileName}" created successfully`
|
||||||
|
const existingConfiguration = await loadAndValidateSettings(
|
||||||
|
projectPath
|
||||||
|
)
|
||||||
|
const settingsToSave = setSettingsAtLevel(
|
||||||
|
existingConfiguration.settings,
|
||||||
|
'project',
|
||||||
|
projectConfigurationToSettingsPayload(unitsConfiguration)
|
||||||
|
)
|
||||||
|
await saveSettings(settingsToSave, projectPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the file
|
||||||
|
let baseDir = window.electron.join(
|
||||||
|
settings.app.projectDirectory.current,
|
||||||
|
projectName
|
||||||
|
)
|
||||||
|
const { name, path } = getNextFileName({
|
||||||
|
entryName: fileName,
|
||||||
|
baseDir,
|
||||||
|
})
|
||||||
|
fileName = name
|
||||||
|
|
||||||
|
await window.electron.writeFile(path, input.code || '')
|
||||||
|
} else {
|
||||||
|
// Browser version doesn't navigate, just overwrites the current file
|
||||||
|
clearImportSearchParams()
|
||||||
|
codeManager.updateCodeStateEditor(input.code || '')
|
||||||
|
await codeManager.writeToFile()
|
||||||
|
message = 'File successfully overwritten with link contents'
|
||||||
|
settingsSend({
|
||||||
|
type: 'set.modeling.defaultUnit',
|
||||||
|
data: {
|
||||||
|
level: 'project',
|
||||||
|
value: input.units,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
fileName,
|
||||||
|
projectName,
|
||||||
|
}
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
guards: {
|
guards: {
|
||||||
'Has at least 1 project': ({ event }) => {
|
'Has at least 1 project': ({ event }) => {
|
||||||
@ -267,6 +384,7 @@ const ProjectsContextDesktop = ({
|
|||||||
state,
|
state,
|
||||||
commandBarConfig: projectsCommandBarConfig,
|
commandBarConfig: projectsCommandBarConfig,
|
||||||
actor,
|
actor,
|
||||||
|
onCancel: clearImportSearchParams,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { base64ToString } from 'lib/base64'
|
import { base64ToString } from 'lib/base64'
|
||||||
import { CREATE_FILE_URL_PARAM, DEFAULT_FILE_NAME } from 'lib/constants'
|
import { CREATE_FILE_URL_PARAM, DEFAULT_FILE_NAME } from 'lib/constants'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { useCommandsContext } from './useCommandsContext'
|
import { useCommandsContext } from './useCommandsContext'
|
||||||
import { useSettingsAuthContext } from './useSettingsAuthContext'
|
import { useSettingsAuthContext } from './useSettingsAuthContext'
|
||||||
import { isDesktop } from 'lib/isDesktop'
|
import { isDesktop } from 'lib/isDesktop'
|
||||||
@ -16,28 +16,28 @@ import { baseUnitsUnion } from 'lib/settings/settingsTypes'
|
|||||||
* URL parameters.
|
* URL parameters.
|
||||||
*/
|
*/
|
||||||
export function useCreateFileLinkQuery() {
|
export function useCreateFileLinkQuery() {
|
||||||
const location = useLocation()
|
const [searchParams] = useSearchParams()
|
||||||
const { commandBarSend } = useCommandsContext()
|
const { commandBarSend } = useCommandsContext()
|
||||||
const { settings } = useSettingsAuthContext()
|
const { settings } = useSettingsAuthContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(location.search)
|
const createFileParam = searchParams.has(CREATE_FILE_URL_PARAM)
|
||||||
const createFileParam = urlParams.has(CREATE_FILE_URL_PARAM)
|
|
||||||
|
|
||||||
console.log('checking for createFileParam', {
|
console.log('checking for createFileParam', {
|
||||||
createFileParam,
|
createFileParam,
|
||||||
urlParams: [...urlParams.entries()],
|
searchParams: [...searchParams.entries()],
|
||||||
location,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (createFileParam) {
|
if (createFileParam) {
|
||||||
const params: FileLinkParams = {
|
const params: FileLinkParams = {
|
||||||
code: base64ToString(decodeURIComponent(urlParams.get('code') ?? '')),
|
code: base64ToString(
|
||||||
|
decodeURIComponent(searchParams.get('code') ?? '')
|
||||||
|
),
|
||||||
|
|
||||||
name: urlParams.get('name') ?? DEFAULT_FILE_NAME,
|
name: searchParams.get('name') ?? DEFAULT_FILE_NAME,
|
||||||
|
|
||||||
units:
|
units:
|
||||||
(baseUnitsUnion.find((unit) => urlParams.get('units') === unit) ||
|
(baseUnitsUnion.find((unit) => searchParams.get('units') === unit) ||
|
||||||
settings.context.modeling.defaultUnit.default) ??
|
settings.context.modeling.defaultUnit.default) ??
|
||||||
settings.context.modeling.defaultUnit.current,
|
settings.context.modeling.defaultUnit.current,
|
||||||
}
|
}
|
||||||
@ -74,5 +74,5 @@ export function useCreateFileLinkQuery() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [location.search])
|
}, [searchParams])
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@ export const useProjectsLoader = (deps?: [number]) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Useless on web, until we get fake filesystems over there.
|
// Useless on web, until we get fake filesystems over there.
|
||||||
if (!isDesktop) return
|
if (!isDesktop()) return
|
||||||
|
|
||||||
if (deps && deps[0] === lastTs) return
|
if (deps && deps[0] === lastTs) return
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
|
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
|
||||||
import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning'
|
import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning'
|
||||||
import { StateMachineCommandSetConfig } from 'lib/commandTypes'
|
import { StateMachineCommandSetConfig } from 'lib/commandTypes'
|
||||||
|
import { isDesktop } from 'lib/isDesktop'
|
||||||
import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes'
|
import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes'
|
||||||
import { projectsMachine } from 'machines/projectsMachine'
|
import { projectsMachine } from 'machines/projectsMachine'
|
||||||
|
|
||||||
@ -112,16 +113,27 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
|
|||||||
inputType: 'options',
|
inputType: 'options',
|
||||||
required: true,
|
required: true,
|
||||||
skip: true,
|
skip: true,
|
||||||
options: [
|
options: isDesktop()
|
||||||
{ name: 'New Project', value: 'newProject' },
|
? [
|
||||||
{ name: 'Existing Project', value: 'existingProject' },
|
{ name: 'New project', value: 'newProject' },
|
||||||
],
|
{ name: 'Existing project', value: 'existingProject' },
|
||||||
|
]
|
||||||
|
: [{ name: 'Overwrite', value: 'existingProject' }],
|
||||||
|
valueSummary(value) {
|
||||||
|
return isDesktop()
|
||||||
|
? value === 'newProject'
|
||||||
|
? 'New project'
|
||||||
|
: 'Existing project'
|
||||||
|
: 'Overwrite'
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// TODO: We can't get the currently-opened project to auto-populate here because
|
// TODO: We can't get the currently-opened project to auto-populate here because
|
||||||
// it's not available on projectMachine, but lower in fileMachine. Unify these.
|
// it's not available on projectMachine, but lower in fileMachine. Unify these.
|
||||||
projectName: {
|
projectName: {
|
||||||
inputType: 'options',
|
inputType: 'options',
|
||||||
required: (commandsContext) => commandsContext.argumentsToSubmit.method === 'existingProject',
|
required: (commandsContext) =>
|
||||||
|
isDesktop() &&
|
||||||
|
commandsContext.argumentsToSubmit.method === 'existingProject',
|
||||||
skip: true,
|
skip: true,
|
||||||
options: [],
|
options: [],
|
||||||
optionsFromContext: (context) =>
|
optionsFromContext: (context) =>
|
||||||
@ -132,13 +144,16 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
|
|||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
inputType: 'string',
|
inputType: 'string',
|
||||||
required: true,
|
required: isDesktop(),
|
||||||
skip: true,
|
skip: true,
|
||||||
},
|
},
|
||||||
code: {
|
code: {
|
||||||
inputType: 'text',
|
inputType: 'text',
|
||||||
required: false,
|
required: true,
|
||||||
skip: true,
|
skip: true,
|
||||||
|
valueSummary(value) {
|
||||||
|
return value?.trim().split('\n').length + ' lines'
|
||||||
|
},
|
||||||
},
|
},
|
||||||
units: {
|
units: {
|
||||||
inputType: 'options',
|
inputType: 'options',
|
||||||
@ -151,7 +166,17 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
reviewMessage(commandBarContext) {
|
reviewMessage(commandBarContext) {
|
||||||
return `Will add the contents from URL to a new ${commandBarContext.argumentsToSubmit.method === 'newProject' ? 'project with file main.kcl' : `file within the project "${commandBarContext.argumentsToSubmit.projectName}"`} named "${commandBarContext.argumentsToSubmit.name}", and set default units to "${commandBarContext.argumentsToSubmit.units}".`
|
return isDesktop()
|
||||||
|
? `Will add the contents from URL to a new ${
|
||||||
|
commandBarContext.argumentsToSubmit.method === 'newProject'
|
||||||
|
? 'project with file main.kcl'
|
||||||
|
: `file within the project "${commandBarContext.argumentsToSubmit.projectName}"`
|
||||||
|
} named "${
|
||||||
|
commandBarContext.argumentsToSubmit.name
|
||||||
|
}", and set default units to "${
|
||||||
|
commandBarContext.argumentsToSubmit.units
|
||||||
|
}".`
|
||||||
|
: `Will overwrite the contents of the current file with the contents from the URL.`
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,26 +1,23 @@
|
|||||||
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
|
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
|
||||||
import { CREATE_FILE_URL_PARAM, PROD_APP_URL } from './constants'
|
import { CREATE_FILE_URL_PARAM } from './constants'
|
||||||
import { stringToBase64 } from './base64'
|
import { stringToBase64 } from './base64'
|
||||||
|
|
||||||
export interface FileLinkParams {
|
export interface FileLinkParams {
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
units: UnitLength_type
|
units: UnitLength_type
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a file's code, name, and units, creates shareable link
|
* Given a file's code, name, and units, creates shareable link
|
||||||
* TODO: make the app respect this link
|
* TODO: make the app respect this link
|
||||||
*/
|
*/
|
||||||
export function createFileLink({
|
export function createFileLink({ code, name, units }: FileLinkParams) {
|
||||||
code,
|
const origin = globalThis.window.location.origin
|
||||||
name,
|
|
||||||
units,
|
|
||||||
}: FileLinkParams) {
|
|
||||||
return new URL(
|
return new URL(
|
||||||
`/?${CREATE_FILE_URL_PARAM}&name=${encodeURIComponent(
|
`/?${CREATE_FILE_URL_PARAM}&name=${encodeURIComponent(
|
||||||
name
|
name
|
||||||
)}&units=${units}&code=${encodeURIComponent(stringToBase64(code))}`,
|
)}&units=${units}&code=${encodeURIComponent(stringToBase64(code))}`,
|
||||||
PROD_APP_URL
|
origin
|
||||||
).href
|
).href
|
||||||
}
|
}
|
||||||
|
@ -104,7 +104,7 @@ export const fileLoader: LoaderFunction = async (
|
|||||||
return redirect(
|
return redirect(
|
||||||
`${PATHS.FILE}/${encodeURIComponent(
|
`${PATHS.FILE}/${encodeURIComponent(
|
||||||
isDesktop() ? fallbackFile : params.id + '/' + PROJECT_ENTRYPOINT
|
isDesktop() ? fallbackFile : params.id + '/' + PROJECT_ENTRYPOINT
|
||||||
)}`
|
)}${new URL(routerData.request.url).search || ''}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,7 +25,10 @@ export const projectsMachine = setup({
|
|||||||
type: 'Delete project'
|
type: 'Delete project'
|
||||||
data: ProjectsCommandSchema['Delete project']
|
data: ProjectsCommandSchema['Delete project']
|
||||||
}
|
}
|
||||||
| { type: 'Import file from URL'; data: ProjectsCommandSchema['Import file from URL'] }
|
| {
|
||||||
|
type: 'Import file from URL'
|
||||||
|
data: ProjectsCommandSchema['Import file from URL']
|
||||||
|
}
|
||||||
| { type: 'navigate'; data: { name: string } }
|
| { type: 'navigate'; data: { name: string } }
|
||||||
| {
|
| {
|
||||||
type: 'xstate.done.actor.read-projects'
|
type: 'xstate.done.actor.read-projects'
|
||||||
@ -43,6 +46,10 @@ export const projectsMachine = setup({
|
|||||||
type: 'xstate.done.actor.rename-project'
|
type: 'xstate.done.actor.rename-project'
|
||||||
output: { message: string; oldName: string; newName: string }
|
output: { message: string; oldName: string; newName: string }
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'xstate.done.actor.create-file'
|
||||||
|
output: { message: string; projectName: string; fileName: string }
|
||||||
|
}
|
||||||
| { type: 'assign'; data: { [key: string]: any } },
|
| { type: 'assign'; data: { [key: string]: any } },
|
||||||
input: {} as {
|
input: {} as {
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
@ -61,6 +68,7 @@ export const projectsMachine = setup({
|
|||||||
toastError: () => {},
|
toastError: () => {},
|
||||||
navigateToProject: () => {},
|
navigateToProject: () => {},
|
||||||
navigateToProjectIfNeeded: () => {},
|
navigateToProjectIfNeeded: () => {},
|
||||||
|
navigateToFile: () => {},
|
||||||
},
|
},
|
||||||
actors: {
|
actors: {
|
||||||
readProjects: fromPromise(() => Promise.resolve([] as Project[])),
|
readProjects: fromPromise(() => Promise.resolve([] as Project[])),
|
||||||
@ -91,13 +99,20 @@ export const projectsMachine = setup({
|
|||||||
name: '',
|
name: '',
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
createFile: fromPromise(
|
||||||
|
(_: {
|
||||||
|
input: ProjectsCommandSchema['Import file from URL'] & {
|
||||||
|
projects: Project[]
|
||||||
|
}
|
||||||
|
}) => Promise.resolve({ message: '', projectName: '', fileName: '' })
|
||||||
|
),
|
||||||
},
|
},
|
||||||
guards: {
|
guards: {
|
||||||
'Has at least 1 project': () => false,
|
'Has at least 1 project': () => false,
|
||||||
'New project method is used': () => false,
|
'New project method is used': () => false,
|
||||||
},
|
},
|
||||||
}).createMachine({
|
}).createMachine({
|
||||||
/** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAMS6yzFSkDaADALqKgAOqtALsaqXYgAHogAsAJgA0IAJ6IAjBIkA2AHQAOCUw0qNkjQE4xYjQF8zMtJhwES5NcmpZSqLBwBOqAFZh8PWAoAJTBcCHcvX39YZjYkEC5efkF40QQFFQBmAHY1Qwz9QwBWU0NMrRl5BGyxBTUVJlVM01rtBXNLEGtsPCIyMEdnVwifPwCKAGEPUJ5sT1H-WKFE4j4BITSMzMzNIsyJDSZMhSYmFWzKxAkTNSYxIuU9zOKT7IsrDB67fsHYEajxiEwv8xjFWMtuKtkhsrpJboZsioincFMdTIjLggVGJ1GImMVMg0JISjEV3l1PrY+g4nH95gDAiFSLgbPSxkt4is1ilQGlrhJ4YjkbU0RoMXJEIYkWoikV8hJinjdOdyd0qfYBrSQdFJtNcLNtTwOZxIdyYQh+YKkSjReKqqYBQoEQpsgiSi9MqrKb0Nb9DYEACJgAA2YANbMW4M5puhqVhAvxQptCnRKkxCleuw0Gm2+2aRVRXpsPp+Woj4wA8hwwKRDcaEjH1nGLXDE9aRSmxWmJVipWocmdsbL8kxC501SWHFMZmQoIaKBABAMyAA3VAAawG+D1swAtOX61zY7zENlEWoMkoatcdPoipjCWIZRIirozsPiYYi19qQNp-rZ3nMAPC8Dw1A4YN9QAM1QDx0DUbcZjAfdInZKMTSSJsT2qc9Lwka8lTvTFTB2WUMiyQwc3yPRv3VH4mRZQDywXJc1FXDcBmmZlMBQhYjXQhtMJ5ERT1wlQr0kQj7kxKiZTxM5s2zbQEVoycBgY9AmNQ-wKGA0DwMgngYLgtQuJZZCDwEo8sJEnD1Dwgjb2kntig0GUkWVfZDEMfFVO+Bwg1DPhSDnZjFwcdjNzUCAQzDCztP4uIMKhGy0jPezxPwySnPvHsTjlPIhyOHRsnwlM-N-NRArDLS+N0kDYIM6DYPgmKgvivjD0bYS+VbBF21RTs7UUUcimfRpXyYRF8LFCrfSBCBaoZFiItINcor1CBeIZLqhPNdKL0yxzs2cqoNDqdpSo0EoSoLOb6NCRaQv9FblzWjjTMe7bQQYBQksElKesUMRjFubRMllCHsm2a5MVldRDBfFpmj0IpxPuhwFqW0F6v0iDmpMzbvuiXbAfNFNQcaI5IaKaH9jETFsUMNRVDxOU5TxBUMcof8DSg4hQ1Js1mwyfCL2JYlakkA4GZcp16jEaGzylfMsm53UkKwfnBb+iE9pFlQDjUM8HiYV99At2WqhOO5+zPLQqbRyiyXJVwYvgeIJ38sA9bJ5td27KpdzG7zQ70VFslOBVim5v1hnLD3kuF7D2hOW5smRCQM2uhQHkZ6VtkRBFnmu0qFFjssEsTgHk9syR03xAUlWOVQ9gzyjY957H-F92u0hMJ8ryc86iUyYiCvxFNzldVvjFjjTu54XvjzrnIWZUfI0eRM2VCyK3JThe5mhdWnS5dj5i29qrYuC0KEuX1LxDxepnlOfYDghp0G+yOpngzN+Yajnno9Re1drJA3SC6HY4lvL6AVMiJEuUqgbzckfQ4+gcTnUJBYCwQA */
|
/** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAMS6yzFSkDaADALqKgAOqtALsaqXYgAHogAsAJgA0IAJ6IAjAHYAbADoArBJVMFTCQA4mTAMwmxAXwsy0mHARLkKASXRcATjywAzYgBtsb3cMLABVACUAGWY2JBAuXn5BONEESRl5BAUFFQM1HQUxJSYATmyFAyUTKxsMbDwiMjA1ZGosUlQsDmCAKzB8HlgKcLBcCC7e-sGYoQTiPgEhVIUy9SKSiQ0NEwkclRyMxGKJNRKlMRVDU23zpRqQW3qHJpa2jonUPoGhgGF3UZ42G6nymMzicwWyVAyzKJzECg0OiYGjERhK+kOWUMSlOGiUlTEJlMxRKBnuj3sjXIr1gHy+g2Go3GwPpsDBnG48ySS0QGj0+Q0JTOGkqBg2JhKmNRJy2OyUEn0Kml5LqlMczVatJZUyGI1IuDs2oG7PinMhPIQfKYAqFShF+PFkrkRwkYjU8OUShKKhMKg0pUs1geqoa6ppdJ1FD+AKBk2NrFmZu5KV5-L9tvtYokEsxelRagUCoMiMumyUdpVdlDL01Ee+FAAImAAoC6zwTRDk9DU9b08LRY7MWd1AZcoS-aoLiYFJWnlSNW0jQyAPIcMCkNsdpOLFOWtOC-sO7NOzLbGWFbY6acmFESWdql7R3B8UhQNsUCACZpkABuqAA1s0+D-M+YAALRLluiQ7t2WRMGIbryjeBgGBIEglNsCi5sY6hMOchQqEoCLFCh97VtST4vm+S4UGA7jBO4agcH4z7eKg7joGowExhBcbtgm4LblCIiKPBiHZiKqHoZhuaFhoBbGMYBhiPBCgStUQYUuRzR6gaZDUXxH5fmov4Ac0-z6pgvEgvGsQctBwnLGJahIZJaEYdOmKEfJuQSoRRKSRcZHPNSunoPp750QxTEsTwbEcWoFkGuBkECfZXIwSJcEIS5Ekoe5MnOggXqIaUqFiiUhIInemkhiFzRNi2EU0Z+1KmYBagQM2YCAtZ9JQRljmiTlrn5dJnlFShOLwcpZjKBs+KBrUVb1WojU9c1hlRexMWsexnFdS2KV8QN5q7laNqHlmOaTcpmjoWc8L6HoYrBfOagjGMm02QyrXfqQf4dSBEB9Tqp1dllebichUkeVhRXTiU+QecUKhCps4pvWGn0QN9rJGW1ANmYlTKg98DAKHZpoORanoyqh8oImU3pKJifJ5Ohdr7CYqjIvKWMvDjeORttjHMXtCXA2T0xpdTg20+W9MSIzgorIRXlpr6ORbPs+jwQLFEgVRPj+JQf0mUTHXcaBYG+AE4OZcsxbuts5hbCK+zFmImJgSc5zPV6BQVD6RQG80lERXblCi7tcX7VxRvgVHDtDQgaE4vK2gXKiTA6ItubmJo8IoqOylERUVhBh0XXwHEWn1YmNO7mBKg+2KpzK2IpIBUwBhqUtwYre9tbvEutfpWdsFFnkPpVJnQqz15Ozur36FoypREbGH4Zj438u7tO1qIu5qK5PBGyYjebpEkS44e9oVTbxHr5tnvk9ZeXajTuW+zaHNuzYRUmoeCEp-RKlUHJbeYVhYDDfhDVIxR5IGGnISQk6E+6EkxMUBQX9c66EMMg3Q5Zt7rWNkuOBjsjilDUAqQsZQzzgOkJNUk7o7SmF-tiXOUCmQwMGBQ1OF4TgVGzFUG8yIxQGDZiYPI4oqh+mPsrDSy05xhmfm+KO-CLT+iRucEUuwFQqWzMWXM2YTAuTtL6ZBylkRcMrkAA */
|
||||||
id: 'Home machine',
|
id: 'Home machine',
|
||||||
|
|
||||||
initial: 'Reading projects',
|
initial: 'Reading projects',
|
||||||
@ -114,13 +129,7 @@ export const projectsMachine = setup({
|
|||||||
target: '.Reading projects',
|
target: '.Reading projects',
|
||||||
},
|
},
|
||||||
|
|
||||||
"Import file from URL": [{
|
'Import file from URL': '.Creating file',
|
||||||
target: ".Creating project",
|
|
||||||
guard: "New project method is used"
|
|
||||||
}, {
|
|
||||||
target: ".Reading projects",
|
|
||||||
actions: "navigateToProject"
|
|
||||||
}]
|
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
'Has no projects': {
|
'Has no projects': {
|
||||||
@ -165,7 +174,10 @@ export const projectsMachine = setup({
|
|||||||
id: 'create-project',
|
id: 'create-project',
|
||||||
src: 'createProject',
|
src: 'createProject',
|
||||||
input: ({ event, context }) => {
|
input: ({ event, context }) => {
|
||||||
if (event.type !== 'Create project' && event.type !== 'Import file from URL') {
|
if (
|
||||||
|
event.type !== 'Create project' &&
|
||||||
|
event.type !== 'Import file from URL'
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
projects: context.projects,
|
projects: context.projects,
|
||||||
@ -280,7 +292,41 @@ export const projectsMachine = setup({
|
|||||||
actions: ['toastError'],
|
actions: ['toastError'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'Creating file': {
|
||||||
|
invoke: {
|
||||||
|
id: 'create-file',
|
||||||
|
src: 'createFile',
|
||||||
|
input: ({ event, context }) => {
|
||||||
|
if (event.type !== 'Import file from URL') {
|
||||||
|
return {
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
units: 'mm',
|
||||||
|
method: 'existingProject',
|
||||||
|
projects: context.projects,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: event.data.code || '',
|
||||||
|
name: event.data.name,
|
||||||
|
units: event.data.units,
|
||||||
|
method: event.data.method,
|
||||||
|
projectName: event.data.projectName,
|
||||||
|
projects: context.projects,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: {
|
||||||
|
target: 'Reading projects',
|
||||||
|
actions: ['navigateToFile', 'toastSuccess'],
|
||||||
|
},
|
||||||
|
onError: {
|
||||||
|
target: 'Reading projects',
|
||||||
|
actions: 'toastError',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -53,7 +53,7 @@ if (require('electron-squirrel-startup')) {
|
|||||||
|
|
||||||
const ZOO_STUDIO_PROTOCOL = 'zoo-studio'
|
const ZOO_STUDIO_PROTOCOL = 'zoo-studio'
|
||||||
|
|
||||||
/// Register our application to handle all "electron-fiddle://" protocols.
|
/// Register our application to handle all "zoo-studio://" protocols.
|
||||||
if (process.defaultApp) {
|
if (process.defaultApp) {
|
||||||
if (process.argv.length >= 2) {
|
if (process.argv.length >= 2) {
|
||||||
app.setAsDefaultProtocolClient(ZOO_STUDIO_PROTOCOL, process.execPath, [
|
app.setAsDefaultProtocolClient(ZOO_STUDIO_PROTOCOL, process.execPath, [
|
||||||
@ -90,6 +90,7 @@ const createWindow = (filePath?: string): BrowserWindow => {
|
|||||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||||
newWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL).catch(reportRejection)
|
newWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL).catch(reportRejection)
|
||||||
} else {
|
} else {
|
||||||
|
console.log('Loading from file', filePath)
|
||||||
getProjectPathAtStartup(filePath)
|
getProjectPathAtStartup(filePath)
|
||||||
.then(async (projectPath) => {
|
.then(async (projectPath) => {
|
||||||
const startIndex = path.join(
|
const startIndex = path.join(
|
||||||
@ -320,6 +321,7 @@ const getProjectPathAtStartup = async (
|
|||||||
// macOS: open-url events that were received before the app is ready
|
// macOS: open-url events that were received before the app is ready
|
||||||
const getOpenUrls: string[] = (global as any).getOpenUrls
|
const getOpenUrls: string[] = (global as any).getOpenUrls
|
||||||
if (getOpenUrls && getOpenUrls.length > 0) {
|
if (getOpenUrls && getOpenUrls.length > 0) {
|
||||||
|
console.log('getOpenUrls', getOpenUrls)
|
||||||
projectPath = getOpenUrls[0] // We only do one project at a
|
projectPath = getOpenUrls[0] // We only do one project at a
|
||||||
}
|
}
|
||||||
// Reset this so we don't accidentally use it again.
|
// Reset this so we don't accidentally use it again.
|
||||||
@ -389,6 +391,8 @@ function registerStartupListeners() {
|
|||||||
) {
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
|
console.log('open-url', url)
|
||||||
|
|
||||||
// If we have a mainWindow, lets open another window.
|
// If we have a mainWindow, lets open another window.
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
createWindow(url)
|
createWindow(url)
|
||||||
|
@ -28,6 +28,7 @@ import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
|
|||||||
// 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 = () => {
|
||||||
|
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
|
||||||
useCreateFileLinkQuery()
|
useCreateFileLinkQuery()
|
||||||
const { state, send } = useProjectsContext()
|
const { state, send } = useProjectsContext()
|
||||||
const { commandBarSend } = useCommandsContext()
|
const { commandBarSend } = useCommandsContext()
|
||||||
@ -207,7 +208,7 @@ const Home = () => {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<Link
|
<Link
|
||||||
to="/?create-file&name=Untitled-1.kcl&units=mm&code=c2tldGNoMDAxID0gc3RhcnRTa2V0Y2hPbignWFknKQogIHw%2BIGNpcmNsZSh7CiAgICAgICBjZW50ZXI6IFstMTUuNTEsIDE1LjMzXSwKICAgICAgIHJhZGl1czogMTIuMTkKICAgICB9LCAlKQpleHRydXNpb25EaXN0YW5jZSA9IDEyCmV4dHJ1ZGUwMDEgPSBleHRydWRlKGV4dHJ1c2lvbkRpc3RhbmNlLCBza2V0Y2gwMDEpCnNrZXRjaDAwMiA9IHN0YXJ0U2tldGNoT24oZXh0cnVkZTAwMSwgJ0VORCcpCiAgfD4gY2lyY2xlKHsKICAgICAgIGNlbnRlcjogWy0xOC42OSwgMjAuMjNdLAogICAgICAgcmFkaXVzOiA0LjgzCiAgICAgfSwgJSkKZXh0cnVkZTAwMiA9IGV4dHJ1ZGUoLWV4dHJ1c2lvbkRpc3RhbmNlLCBza2V0Y2gwMDIpCgo%3D"
|
to="/?create-file&name=Untitled-1.kcl&units=in&code=c2tldGNoMDAxID0gc3RhcnRTa2V0Y2hPbignWFknKQogIHw%2BIGNpcmNsZSh7CiAgICAgICBjZW50ZXI6IFstMTUuNTEsIDE1LjMzXSwKICAgICAgIHJhZGl1czogMTIuMTkKICAgICB9LCAlKQpleHRydXNpb25EaXN0YW5jZSA9IDEyCmV4dHJ1ZGUwMDEgPSBleHRydWRlKGV4dHJ1c2lvbkRpc3RhbmNlLCBza2V0Y2gwMDEpCnNrZXRjaDAwMiA9IHN0YXJ0U2tldGNoT24oZXh0cnVkZTAwMSwgJ0VORCcpCiAgfD4gY2lyY2xlKHsKICAgICAgIGNlbnRlcjogWy0xOC42OSwgMjAuMjNdLAogICAgICAgcmFkaXVzOiA0LjgzCiAgICAgfSwgJSkKZXh0cnVkZTAwMiA9IGV4dHJ1ZGUoLWV4dHJ1c2lvbkRpc3RhbmNlLCBza2V0Y2gwMDIpCgo%3D"
|
||||||
className="text-primary underline underline-offset-2"
|
className="text-primary underline underline-offset-2"
|
||||||
>
|
>
|
||||||
Go to a test create-file link
|
Go to a test create-file link
|
||||||
|
Reference in New Issue
Block a user