Organization and Pro tier link sharing features exposure (#6986)

* Improve url sharing for orgs and pros

* Remove sharing via menu item

* fmt

* Not sure what's different but it is

* fmt & lint

* whoops

* Update snapshots

* Typos from codespell

* Fix alignment

* Update snapshots

* Prune

---------

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
Co-authored-by: Pierre Jacquier <pierre@zoo.dev>
This commit is contained in:
Zookeeper Lee
2025-05-16 05:51:08 -04:00
committed by GitHub
parent 17eb84325f
commit 48a4fd8373
36 changed files with 259 additions and 117 deletions

View File

@ -197,18 +197,6 @@ test.describe(
await clickElectronNativeMenuById(tronApp, 'File.Export current part') await clickElectronNativeMenuById(tronApp, 'File.Export current part')
await cmdBar.expectCommandName('Export') await cmdBar.expectCommandName('Export')
}) })
await test.step('Modeling.File.Share part via Zoo link', async () => {
await page.waitForTimeout(250)
await clickElectronNativeMenuById(
tronApp,
'File.Share part via Zoo link'
)
const textToCheck =
'Link copied to clipboard. Anyone who clicks this link will get a copy of this file. Share carefully!'
// Check if text appears anywhere in the page
const isTextVisible = page.getByText(textToCheck)
await expect(isTextVisible).toBeVisible({ timeout: 10000 })
})
await test.step('Modeling.File.Preferences.Project settings', async () => { await test.step('Modeling.File.Preferences.Project settings', async () => {
await page.waitForTimeout(250) await page.waitForTimeout(250)
await clickElectronNativeMenuById( await clickElectronNativeMenuById(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -2770,7 +2770,7 @@ mod clone_w_fillets {
/// Test that KCL is executed correctly. /// Test that KCL is executed correctly.
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
#[ignore] // turn on when https://github.com/KittyCAD/engine/pull/3380 is merged #[ignore] // turn on when https://github.com/KittyCAD/engine/pull/3380 is merged
// Theres also a test in clone.rs you need to turn too // There's also a test in clone.rs you need to turn too
async fn kcl_test_execute() { async fn kcl_test_execute() {
super::execute(TEST_NAME, true).await super::execute(TEST_NAME, true).await
} }

View File

@ -15,15 +15,9 @@ import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
import usePlatform from '@src/hooks/usePlatform' import usePlatform from '@src/hooks/usePlatform'
import { APP_NAME } from '@src/lib/constants' import { APP_NAME } from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop' import { isDesktop } from '@src/lib/isDesktop'
import { copyFileShareLink } from '@src/lib/links'
import { PATHS } from '@src/lib/paths' import { PATHS } from '@src/lib/paths'
import { import { engineCommandManager, kclManager } from '@src/lib/singletons'
codeManager,
engineCommandManager,
kclManager,
} from '@src/lib/singletons'
import { type IndexLoaderData } from '@src/lib/types' import { type IndexLoaderData } from '@src/lib/types'
import { useToken } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons' import { commandBarActor } from '@src/lib/singletons'
const ProjectSidebarMenu = ({ const ProjectSidebarMenu = ({
@ -108,7 +102,6 @@ function ProjectMenuPopover({
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
const token = useToken()
const machineManager = useContext(MachineManagerContext) const machineManager = useContext(MachineManagerContext)
const commands = useSelector(commandBarActor, commandsSelector) const commands = useSelector(commandBarActor, commandsSelector)
@ -116,7 +109,6 @@ function ProjectMenuPopover({
const insertCommandInfo = { name: 'Insert', groupId: 'code' } const insertCommandInfo = { name: 'Insert', groupId: 'code' }
const exportCommandInfo = { name: 'Export', groupId: 'modeling' } const exportCommandInfo = { name: 'Export', groupId: 'modeling' }
const makeCommandInfo = { name: 'Make', groupId: 'modeling' } const makeCommandInfo = { name: 'Make', groupId: 'modeling' }
const shareCommandInfo = { name: 'share-file-link', groupId: 'code' }
const findCommand = (obj: { name: string; groupId: string }) => const findCommand = (obj: { name: string; groupId: string }) =>
Boolean( Boolean(
commands.find((c) => c.name === obj.name && c.groupId === obj.groupId) commands.find((c) => c.name === obj.name && c.groupId === obj.groupId)
@ -217,19 +209,6 @@ function ProjectMenuPopover({
}) })
}, },
}, },
{
id: 'share-link',
Element: 'button',
children: 'Share part via Zoo link',
disabled: !findCommand(shareCommandInfo),
onClick: async () => {
await copyFileShareLink({
token: token ?? '',
code: codeManager.code,
name: project?.name || '',
})
},
},
'break', 'break',
{ {
id: 'go-home', id: 'go-home',

View File

@ -1,32 +1,93 @@
import { err } from '@src/lib/trap'
import { CustomIcon } from '@src/components/CustomIcon' import { CustomIcon } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip' import Tooltip from '@src/components/Tooltip'
import usePlatform from '@src/hooks/usePlatform' import usePlatform from '@src/hooks/usePlatform'
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper' import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
import { commandBarActor } from '@src/lib/singletons' import { billingActor, commandBarActor } from '@src/lib/singletons'
import { useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { Popover } from '@headlessui/react'
import { useSelector } from '@xstate/react'
import { useKclContext } from '@src/lang/KclProvider' import { useKclContext } from '@src/lang/KclProvider'
import { Tier } from '@src/machines/billingMachine'
import type { SubscriptionsOrError } from '@src/machines/billingMachine'
const shareHotkey = 'mod+alt+s' const shareHotkey = 'mod+alt+s'
const onShareClick = () =>
commandBarActor.send({ const canPasswordProtectShareLinks = (
type: 'Find and select command', subOrErr: undefined | SubscriptionsOrError
data: { name: 'share-file-link', groupId: 'code' }, ): boolean => {
}) if (subOrErr === undefined || typeof subOrErr === 'number' || err(subOrErr))
return false
return subOrErr.modeling_app.share_links[0] === 'password_protected'
}
/** Share Zoo link button shown in the upper-right of the modeling view */ /** Share Zoo link button shown in the upper-right of the modeling view */
export const ShareButton = () => { export const ShareButton = () => {
const platform = usePlatform() const platform = usePlatform()
useHotkeys(shareHotkey, onShareClick, {
const [showOptions, setShowOptions] = useState(false)
const [isRestrictedToOrg, setIsRestrictedToOrg] = useState(false)
const [password, setPassword] = useState('')
const billingContext = useSelector(billingActor, ({ context }) => context)
const allowOrgRestrict = billingContext.tier === Tier.Organization
const allowPassword = canPasswordProtectShareLinks(
billingContext.subscriptionsOrError
)
const hasOptions = allowOrgRestrict || allowPassword
// Prevents Organization and Pro tier users from one-click sharing,
// and give them a chance to set a password and restrict to org.
const onShareClickFreeOrUnknownRestricted = () => {
if (hasOptions) {
setShowOptions(true)
return
}
commandBarActor.send({
type: 'Find and select command',
data: {
name: 'share-file-link',
groupId: 'code',
isRestrictedToOrg: false,
},
})
}
const onShareClickProOrOrganization = () => {
setShowOptions(false)
commandBarActor.send({
type: 'Find and select command',
data: {
name: 'share-file-link',
groupId: 'code',
isRestrictedToOrg,
password,
},
})
}
useHotkeys(shareHotkey, onShareClickFreeOrUnknownRestricted, {
scopes: ['modeling'], scopes: ['modeling'],
}) })
const kclContext = useKclContext() const kclContext = useKclContext()
const disabled = kclContext.ast.body.some((n) => n.type === 'ImportStatement')
// It doesn't make sense for the user to be able to click on this
// until we get what their subscription allows for.
const disabled =
kclContext.ast.body.some((n) => n.type === 'ImportStatement') ||
billingContext.tier === undefined
return ( return (
<Popover className="relative flex">
<Popover.Button className="relative group border-0 w-fit min-w-max p-0 rounded-l-full focus-visible:outline-appForeground">
<button <button
type="button" type="button"
onClick={onShareClick} onClick={onShareClickFreeOrUnknownRestricted}
disabled={disabled} disabled={disabled}
className="flex gap-1 items-center py-0 pl-0.5 pr-1.5 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 border border-solid active:border-primary" className="flex gap-1 items-center py-0 pl-0.5 pr-1.5 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 border border-solid active:border-primary"
data-testid="share-button" data-testid="share-button"
@ -49,5 +110,59 @@ export const ShareButton = () => {
)} )}
</Tooltip> </Tooltip>
</button> </button>
</Popover.Button>
{showOptions && (
<Popover.Panel
focus={true}
className={`z-10 absolute top-full right-0 mt-1 pb-1 w-48 bg-chalkboard-10 dark:bg-chalkboard-90
border border-solid border-chalkboard-20 dark:border-chalkboard-90 rounded
shadow-lg`}
>
<div className="flex flex-col px-2">
<div className="flex flex-row gap-1 items-center">
<CustomIcon name="lockClosed" className="w-6 h-6" />
<input
disabled={!allowPassword}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
className={`${allowPassword ? 'cursor-pointer' : 'cursor-not-allowed'} text-xs w-full py-1 bg-transparent text-chalkboard-100 placeholder:text-chalkboard-70 dark:text-chalkboard-10 dark:placeholder:text-chalkboard-50 focus:outline-none focus:ring-0`}
type="text"
placeholder="Set a password"
/>
</div>
<div>
<label
htmlFor="org-only"
className="pl-1 inline-flex items-center"
>
<input
disabled={!allowOrgRestrict}
checked={isRestrictedToOrg}
onChange={(_) => setIsRestrictedToOrg(!isRestrictedToOrg)}
type="checkbox"
name="org-only"
className="form-checkbox"
/>
<span
className={`text-xs ml-2 ${allowOrgRestrict ? 'cursor-pointer' : 'cursor-not-allowed text-chalkboard-50'}`}
>
Org. only access
</span>
</label>
{!allowOrgRestrict && (
<Tooltip>Upgrade to Organization to use this feature.</Tooltip>
)}
</div>
<button disabled={disabled} onClick={onShareClickProOrOrganization}>
Generate
</button>
</div>
</Popover.Panel>
)}
</Popover>
) )
} }

View File

@ -49,6 +49,7 @@ export function useCreateFileLinkQuery(
decodeURIComponent(searchParams.get('code') ?? '') decodeURIComponent(searchParams.get('code') ?? '')
), ),
name: searchParams.get('name') ?? DEFAULT_FILE_NAME, name: searchParams.get('name') ?? DEFAULT_FILE_NAME,
isRestrictedToOrg: false,
} }
const argDefaultValues: CreateFileSchemaMethodOptional = { const argDefaultValues: CreateFileSchemaMethodOptional = {

View File

@ -32,6 +32,8 @@ interface KclCommandConfig {
settings: { settings: {
defaultUnit: UnitLength_type defaultUnit: UnitLength_type
} }
isRestrictedToOrg?: boolean
password?: string
} }
export function kclCommands(commandProps: KclCommandConfig): Command[] { export function kclCommands(commandProps: KclCommandConfig): Command[] {
@ -175,11 +177,13 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
groupId: 'code', groupId: 'code',
needsReview: false, needsReview: false,
icon: 'link', icon: 'link',
onSubmit: () => { onSubmit: (input) => {
copyFileShareLink({ copyFileShareLink({
token: commandProps.authToken, token: commandProps.authToken,
code: codeManager.code, code: codeManager.code,
name: commandProps.projectData.project?.name || '', name: commandProps.projectData.project?.name || '',
isRestrictedToOrg: input?.event.data.isRestrictedToOrg ?? false,
password: input?.event.data.password,
}).catch(reportRejection) }).catch(reportRejection)
}, },
}, },

View File

@ -11,7 +11,7 @@ describe(`link creation tests`, () => {
const expectedEncodedCode = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D` const expectedEncodedCode = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D`
const expectedLink = `${VITE_KC_SITE_APP_URL}/?create-file=true&name=test&code=${expectedEncodedCode}&ask-open-desktop=true` const expectedLink = `${VITE_KC_SITE_APP_URL}/?create-file=true&name=test&code=${expectedEncodedCode}&ask-open-desktop=true`
const result = createCreateFileUrl({ code, name }) const result = createCreateFileUrl({ code, name, isRestrictedToOrg: false })
expect(result.toString()).toBe(expectedLink) expect(result.toString()).toBe(expectedLink)
}) })
}) })

View File

@ -11,6 +11,8 @@ import { err } from '@src/lib/trap'
export interface FileLinkParams { export interface FileLinkParams {
code: string code: string
name: string name: string
isRestrictedToOrg: boolean
password?: string
} }
export async function copyFileShareLink( export async function copyFileShareLink(
@ -24,7 +26,12 @@ export async function copyFileShareLink(
return return
} }
const shareUrl = createCreateFileUrl(args) const shareUrl = createCreateFileUrl(args)
const shortlink = await createShortlink(token, shareUrl.toString()) const shortlink = await createShortlink(
token,
shareUrl.toString(),
args.isRestrictedToOrg,
args.password
)
if (err(shortlink)) { if (err(shortlink)) {
toast.error(shortlink.message, { toast.error(shortlink.message, {
@ -70,23 +77,32 @@ export function createCreateFileUrl({ code, name }: FileLinkParams) {
*/ */
export async function createShortlink( export async function createShortlink(
token: string, token: string,
url: string url: string,
isRestrictedToOrg: boolean,
password?: string
): Promise<Error | { key: string; url: string }> { ): Promise<Error | { key: string; url: string }> {
/** /**
* We don't use our `withBaseURL` function here because * We don't use our `withBaseURL` function here because
* there is no URL shortener service in the dev API. * there is no URL shortener service in the dev API.
*/ */
const body: {
url: string
restrict_to_org: boolean
password?: string
} = {
url,
restrict_to_org: isRestrictedToOrg,
}
if (password) {
body.password = password
}
const response = await fetch(`${VITE_KC_API_BASE_URL}/user/shortlinks`, { const response = await fetch(`${VITE_KC_API_BASE_URL}/user/shortlinks`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-type': 'application/json', 'Content-type': 'application/json',
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify(body),
url,
// In future we can support org-scoped and password-protected shortlinks here
// https://zoo.dev/docs/api/shortlinks/create-a-shortlink-for-a-user?lang=typescript
}),
}) })
if (!response.ok) { if (!response.ok) {
const error = await response.json() const error = await response.json()

View File

@ -14,11 +14,34 @@ export enum BillingTransition {
Wait = 'wait', Wait = 'wait',
} }
// It's nice to be explicit if we are an Organization, Pro, Free.
// @kittycad/lib offers some types around this, but they aren't as...
// homogeneous: Models['ZooProductSubscriptions_type'], and
// Models['Org_type'].
export enum Tier {
Free = 'free',
Pro = 'pro',
Organization = 'organization',
Unknown = 'unknown',
}
export type OrgOrError = Models['Org_type'] | number | Error
export type SubscriptionsOrError =
| Models['ZooProductSubscriptions_type']
| number
| Error
export type TierBasedOn = {
orgOrError: OrgOrError
subscriptionsOrError: SubscriptionsOrError
}
export interface BillingContext { export interface BillingContext {
credits: undefined | number credits: undefined | number
allowance: undefined | number allowance: undefined | number
error: undefined | Error error: undefined | Error
urlUserService: string urlUserService: string
tier: undefined | Tier
subscriptionsOrError: undefined | SubscriptionsOrError
} }
export interface BillingUpdateEvent { export interface BillingUpdateEvent {
@ -30,9 +53,30 @@ export const BILLING_CONTEXT_DEFAULTS: BillingContext = Object.freeze({
credits: undefined, credits: undefined,
allowance: undefined, allowance: undefined,
error: undefined, error: undefined,
tier: undefined,
subscriptionsOrError: undefined,
urlUserService: '', urlUserService: '',
}) })
const toTierFrom = (args: TierBasedOn): Tier => {
if (typeof args.orgOrError !== 'number' && !err(args.orgOrError)) {
return Tier.Organization
} else if (
typeof args.subscriptionsOrError !== 'number' &&
!err(args.subscriptionsOrError)
) {
const subscriptions: Models['ZooProductSubscriptions_type'] =
args.subscriptionsOrError
if (subscriptions.modeling_app.name === 'pro') {
return Tier.Pro
} else {
return Tier.Free
}
}
return Tier.Unknown
}
export const billingMachine = setup({ export const billingMachine = setup({
types: { types: {
context: {} as BillingContext, context: {} as BillingContext,
@ -72,33 +116,45 @@ export const billingMachine = setup({
input.event.apiToken input.event.apiToken
) )
const tier = toTierFrom({
orgOrError,
subscriptionsOrError,
})
let credits = let credits =
Number(billing.monthly_api_credits_remaining) + Number(billing.monthly_api_credits_remaining) +
Number(billing.stable_api_credits_remaining) Number(billing.stable_api_credits_remaining)
let allowance = undefined let allowance = undefined
// If user is part of an org, the endpoint will return data. switch (tier) {
if (typeof orgOrError !== 'number' && !err(orgOrError)) { case Tier.Organization:
case Tier.Pro:
credits = Infinity credits = Infinity
// Otherwise they are on a Pro or Free subscription break
} else if ( case Tier.Free:
// TS too dumb Tier.Free has the same logic
if (
typeof subscriptionsOrError !== 'number' && typeof subscriptionsOrError !== 'number' &&
!err(subscriptionsOrError) !err(subscriptionsOrError)
) { ) {
const subscriptions: Models['ZooProductSubscriptions_type'] =
subscriptionsOrError
if (subscriptions.modeling_app.name === 'pro') {
credits = Infinity
} else {
allowance = Number( allowance = Number(
subscriptions.modeling_app.monthly_pay_as_you_go_api_credits subscriptionsOrError.modeling_app
.monthly_pay_as_you_go_api_credits
) )
} }
break
case Tier.Unknown:
break
default:
const _exh: never = tier
} }
// If nothing matches, we show a credit total. // If nothing matches, we show a credit total.
return { return {
error: undefined, error: undefined,
tier,
subscriptionsOrError,
credits, credits,
allowance, allowance,
} }

View File

@ -66,6 +66,10 @@ export type CommandBarMachineEvent =
name: string name: string
groupId: string groupId: string
argDefaultValues?: { [x: string]: unknown } argDefaultValues?: { [x: string]: unknown }
// I'm sorry but the way we did share URL called for this.
isRestrictedToOrg?: boolean
password?: string
} }
} }
| { | {
@ -115,7 +119,7 @@ export const commandBarMachine = setup({
} }
selectedCommand?.onSubmit(resolvedArgs) selectedCommand?.onSubmit(resolvedArgs)
} else { } else {
selectedCommand?.onSubmit() selectedCommand?.onSubmit({ context, event })
} }
}, },
'Clear selected command': assign({ 'Clear selected command': assign({

View File

@ -26,7 +26,6 @@ export type MenuLabels =
| 'File.Create new folder' | 'File.Create new folder'
| 'File.Add file to project' | 'File.Add file to project'
| 'File.Export current part' | 'File.Export current part'
| 'File.Share part via Zoo link'
| 'File.Preferences.Project settings' | 'File.Preferences.Project settings'
| 'Design.Start sketch' | 'Design.Start sketch'
| 'Design.Create an offset plane' | 'Design.Create an offset plane'

View File

@ -184,15 +184,6 @@ export const modelingFileRole = (
}) })
}, },
}, },
{
label: 'Share part via Zoo link',
id: 'File.Share part via Zoo link',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Share part via Zoo link',
})
},
},
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Preferences', label: 'Preferences',

View File

@ -1,13 +1,8 @@
import { AxisNames } from '@src/lib/constants' import { AxisNames } from '@src/lib/constants'
import { copyFileShareLink } from '@src/lib/links'
import { PATHS } from '@src/lib/paths' import { PATHS } from '@src/lib/paths'
import type { Project } from '@src/lib/project' import type { Project } from '@src/lib/project'
import type { SettingsType } from '@src/lib/settings/initialSettings' import type { SettingsType } from '@src/lib/settings/initialSettings'
import { import { engineCommandManager, sceneInfra } from '@src/lib/singletons'
codeManager,
engineCommandManager,
sceneInfra,
} from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap' import { reportRejection } from '@src/lib/trap'
import { uuidv4 } from '@src/lib/utils' import { uuidv4 } from '@src/lib/utils'
import { authActor, settingsActor } from '@src/lib/singletons' import { authActor, settingsActor } from '@src/lib/singletons'
@ -84,12 +79,6 @@ export function modelingMenuCallbackMostActions(
}) })
} else if (data.menuLabel === 'File.Preferences.Theme color') { } else if (data.menuLabel === 'File.Preferences.Theme color') {
navigate(filePath + PATHS.SETTINGS_USER + '#themeColor') navigate(filePath + PATHS.SETTINGS_USER + '#themeColor')
} else if (data.menuLabel === 'File.Share part via Zoo link') {
copyFileShareLink({
token: token ?? '',
code: codeManager.code,
name: project?.name || '',
}).catch(reportRejection)
} else if (data.menuLabel === 'File.Preferences.User default units') { } else if (data.menuLabel === 'File.Preferences.User default units') {
navigate(filePath + PATHS.SETTINGS_USER + '#defaultUnit') navigate(filePath + PATHS.SETTINGS_USER + '#defaultUnit')
} else if (data.menuLabel === 'File.Add file to project') { } else if (data.menuLabel === 'File.Add file to project') {