Settings improvements (#10924)

- Address https://github.com/enso-org/cloud-v2/issues/1457
- Show x instead of a circle for close tab icon on Windows and Linux
- Free / Solo no longer shows Organization, Members, User Groups, and Activity Log
- Invite button should only be there for the admin in Team or above (should already be present)
- Should list the required symbols in the password change.
- Changing headshot appeared briefly and then disappeared.
- Fix https://github.com/enso-org/cloud-v2/issues/1455
- The `Alert` component no longer focuses itself on every keystroke.
- Fix #10988
- Same issue as above.

Other related changes:
- Fix word wrapping in tooltips when a word is wider than the entire tooltip
- Add word wrapping to "members" table in members tab
- Limit length of "email" column of members table in "user groups" tab
- Hide empty sections
- Add text to empty "user groups" table in "user groups" tab
- ~~Close "set organization name" modal when organization name is set~~
- Already fixed by another PR

# Important Notes
None
This commit is contained in:
somebody1234 2024-09-07 00:29:26 +10:00 committed by GitHub
parent c87053d600
commit 3bcc694af9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 209 additions and 129 deletions

View File

@ -6,49 +6,80 @@
"request": "launch",
"name": "Dashboard",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-dashboard", "dev"]
"runtimeArgs": ["run", "--filter", "enso-dashboard", "dev"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (Electron, Linux)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso", "watch:linux"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (Electron, macOS)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso", "watch:macos"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (Electron, Windows)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso", "watch:windows"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "GUI",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev:gui"]
"runtimeArgs": ["dev:gui"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "GUI (Storybook)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-gui2", "story:dev"]
"runtimeArgs": ["run", "--filter", "enso-gui2", "story:dev"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (Build)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["build:gui"]
"runtimeArgs": ["build:gui"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (E2E UI)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:e2e:debug"]
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:e2e:debug"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "GUI (E2E UI)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:e2e", "--", "--ui"]
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:e2e", "--", "--ui"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (All tests)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test"]
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test"],
"outputCapture": "std"
},
{
"type": "node",

View File

@ -1,10 +1,16 @@
/** @file Test the organization settings tab. */
import * as test from '@playwright/test'
import { Plan } from 'enso-common/src/services/Backend'
import * as actions from './actions'
test.test('organization settings', async ({ page }) => {
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const api = await actions.mockAllAndLoginAndExposeAPI({
page,
setupAPI: (theApi) => {
theApi.setPlan(Plan.team)
},
})
const localActions = actions.settings.organization
// Setup
@ -76,7 +82,12 @@ test.test('organization settings', async ({ page }) => {
})
test.test('upload organization profile picture', async ({ page }) => {
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const api = await actions.mockAllAndLoginAndExposeAPI({
page,
setupAPI: (theApi) => {
theApi.setPlan(Plan.team)
},
})
const localActions = actions.settings.organizationProfilePicture
await localActions.go(page)

View File

@ -1,7 +1,6 @@
/** @file Alert component. */
import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react'
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
@ -57,6 +56,7 @@ export interface AlertProps
HTMLAttributes<HTMLDivElement> {}
/** Alert component. */
// eslint-disable-next-line no-restricted-syntax
export const Alert = forwardRef(function Alert(
props: AlertProps,
ref: ForwardedRef<HTMLDivElement>,
@ -71,11 +71,7 @@ export const Alert = forwardRef(function Alert(
return (
<div
className={ALERT_STYLES({ variant, size, className, rounded, fullWidth })}
ref={mergeRefs(ref, (e) => {
if (variant === 'error') {
e?.focus()
}
})}
ref={ref}
{...containerProps}
>
{children}

View File

@ -1,49 +1,44 @@
/** @file A button for closing a modal. */
import * as React from 'react'
import Dismiss from '#/assets/dismiss.svg'
import * as textProvider from '#/providers/TextProvider'
import * as button from '#/components/AriaComponents/Button'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import DismissIcon from '#/assets/dismiss.svg'
import { Button, type ButtonProps } from '#/components/AriaComponents/Button'
import { useText } from '#/providers/TextProvider'
import { twMerge } from '#/utilities/tailwindMerge'
import { isOnMacOS } from 'enso-common/src/detect'
// ===================
// === CloseButton ===
// ===================
/** Props for a {@link CloseButton}. */
export type CloseButtonProps = Omit<
button.ButtonProps,
'children' | 'rounding' | 'size' | 'variant'
>
export type CloseButtonProps = Omit<ButtonProps, 'children' | 'rounding' | 'size' | 'variant'>
/** A styled button with a close icon that appears on hover. */
export function CloseButton(props: CloseButtonProps) {
const { getText } = textProvider.useText()
const { getText } = useText()
const {
className,
icon = Dismiss,
icon = DismissIcon,
tooltip = false,
'aria-label': ariaLabel = getText('closeModalShortcut'),
...buttonProps
} = props
return (
<button.Button
<Button
variant="icon"
className={(values) =>
tailwindMerge.twMerge(
'bg-primary/30 hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1',
twMerge(
'hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1',
isOnMacOS() ? 'bg-primary/30' : (
'text-primary/90 hover:text-primary focus-visible:text-primary'
),
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
// @ts-expect-error ts fails to infer the type of the className prop
typeof className === 'function' ? className(values) : className,
)
}
tooltip={tooltip}
showIconOnHover
showIconOnHover={isOnMacOS()}
size="xsmall"
rounded="full"
extraClickZone="medium"

View File

@ -12,7 +12,7 @@ import * as text from '../Text'
// =================
export const TOOLTIP_STYLES = twv.tv({
base: 'group flex justify-center items-center text-center text-balance break-all z-50',
base: 'group flex justify-center items-center text-center text-balance [overflow-wrap:anywhere] z-tooltip',
variants: {
variant: {
custom: '',

View File

@ -9,9 +9,9 @@ import * as reactQuery from '@tanstack/react-query'
import { IS_DEV_MODE } from 'enso-common/src/detect'
import cross from '#/assets/cross.svg'
import CrossIcon from '#/assets/cross.svg'
import DevtoolsLogo from '#/assets/enso_logo.svg'
import trash from '#/assets/trash.svg'
import TrashIcon from '#/assets/trash.svg'
import { SETUP_PATH } from '#/appUtils'
@ -45,7 +45,7 @@ import {
} from '#/providers/FeatureFlagsProvider'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import * as backend from '#/services/Backend'
import LocalStorage from '#/utilities/LocalStorage'
import LocalStorage, { type LocalStorageData } from '#/utilities/LocalStorage'
import { unsafeEntries } from 'enso-common/src/utilities/data/object'
/**
@ -60,6 +60,10 @@ export function EnsoDevtools() {
const enableVersionChecker = useEnableVersionChecker()
const setEnableVersionChecker = useSetEnableVersionChecker()
const { localStorage } = useLocalStorage()
const [localStorageState, setLocalStorageState] = React.useState<Partial<LocalStorageData>>({})
// Re-render when localStorage changes.
React.useEffect(() => localStorage.subscribeAll(setLocalStorageState), [localStorage])
const featureFlags = useFeatureFlags()
const setFeatureFlags = useSetFeatureFlags()
@ -157,46 +161,6 @@ export function EnsoDevtools() {
)}
</ariaComponents.Form>
<Separator orientation="horizontal" className="my-3" />
<div className="mb-2 flex w-full items-center justify-between">
<Text variant="subtitle">{getText('localStorage')}</Text>
<Button
aria-label={getText('deleteAll')}
size="small"
variant="icon"
icon={trash}
onPress={() => {
for (const [key] of unsafeEntries(LocalStorage.keyMetadata)) {
localStorage.delete(key)
}
}}
/>
</div>
<div className="flex flex-col gap-0.5">
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
<div key={key} className="flex w-full items-center justify-between gap-1">
<Text variant="body">
{key
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
.replace(/^./, (m) => m.toUpperCase())}
</Text>
<Button
variant="icon"
size="small"
aria-label={getText('delete')}
icon={cross}
onPress={() => {
localStorage.delete(key)
}}
/>
</div>
))}
</div>
<ariaComponents.Separator orientation="horizontal" className="my-3" />
<ariaComponents.Text variant="subtitle" className="mb-2">
@ -292,6 +256,47 @@ export function EnsoDevtools() {
)
})}
</ariaComponents.Form>
<Separator orientation="horizontal" className="my-3" />
<div className="mb-2 flex w-full items-center justify-between">
<Text variant="subtitle">{getText('localStorage')}</Text>
<Button
aria-label={getText('deleteAll')}
size="small"
variant="icon"
icon={TrashIcon}
onPress={() => {
for (const [key] of unsafeEntries(LocalStorage.keyMetadata)) {
localStorage.delete(key)
}
}}
/>
</div>
<div className="flex flex-col gap-0.5">
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
<div key={key} className="flex w-full items-center justify-between gap-1">
<Text variant="body">
{key
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
.replace(/^./, (m) => m.toUpperCase())}
</Text>
<Button
variant="icon"
size="small"
isDisabled={localStorageState[key] == null}
aria-label={getText('delete')}
icon={CrossIcon}
onPress={() => {
localStorage.delete(key)
}}
/>
</div>
))}
</div>
</Popover>
</ariaComponents.DialogTrigger>
</Portal>

View File

@ -3,7 +3,7 @@ import { Button, type ButtonProps } from '#/components/AriaComponents'
import { tv } from '#/utilities/tailwindVariants'
const SIDEBAR_TAB_BUTTON_STYLES = tv({
base: 'font-medium',
base: 'z-1 font-medium',
variants: {
isActive: { true: 'bg-white opacity-100' },
},

View File

@ -1,6 +1,4 @@
/** @file Settings tab for viewing and editing organization members. */
import * as React from 'react'
import { useMutation, useSuspenseQueries } from '@tanstack/react-query'
import { backendMutationOptions } from '#/hooks/backendHooks'
@ -92,10 +90,10 @@ export default function MembersSettingsSection() {
<table className="table-fixed self-start rounded-rows">
<thead>
<tr className="h-row">
<th className="w-members-name-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
<th className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
{getText('name')}
</th>
<th className="w-members-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
<th className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
{getText('status')}
</th>
</tr>
@ -103,9 +101,13 @@ export default function MembersSettingsSection() {
<tbody className="select-text">
{members.map((member) => (
<tr key={member.email} className="group h-row rounded-rows-child">
<td className="border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
<span className="block text-sm">{member.email}</span>
<span className="block text-xs text-primary/50">{member.name}</span>
<td className="max-w-48 border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
<ariaComponents.Text truncate="1" className="block">
{member.email}
</ariaComponents.Text>
<ariaComponents.Text truncate="1" className="block text-2xs text-primary/40">
{member.name}
</ariaComponents.Text>
</td>
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
<div className="flex flex-col">

View File

@ -132,11 +132,11 @@ export default function MembersTable(props: MembersTableProps) {
<aria.TableHeader className="sticky top h-row">
<aria.Column
isRowHeader
className="w-members-name-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
>
{getText('name')}
</aria.Column>
<aria.Column className="w-members-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
<aria.Column className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
{getText('email')}
</aria.Column>
{/* Delete button. */}

View File

@ -1,13 +1,9 @@
/** @file Rendering for a settings section. */
import * as React from 'react'
import * as textProvider from '#/providers/TextProvider'
import type * as settingsData from '#/layouts/Settings/settingsData'
import SettingsEntry from '#/layouts/Settings/SettingsEntry'
import * as aria from '#/components/aria'
import { Text } from '#/components/AriaComponents'
import FocusArea from '#/components/styled/FocusArea'
import type { SettingsContext, SettingsSectionData } from '#/layouts/Settings/settingsData'
import SettingsEntry from '#/layouts/Settings/SettingsEntry'
import { useText } from '#/providers/TextProvider'
// =======================
// === SettingsSection ===
@ -15,24 +11,25 @@ import FocusArea from '#/components/styled/FocusArea'
/** Props for a {@link SettingsSection}. */
export interface SettingsSectionProps {
readonly context: settingsData.SettingsContext
readonly data: settingsData.SettingsSectionData
readonly context: SettingsContext
readonly data: SettingsSectionData
}
/** Rendering for a settings section. */
export default function SettingsSection(props: SettingsSectionProps) {
const { context, data } = props
const { nameId, focusArea = true, heading = true, entries } = data
const { getText } = textProvider.useText()
const { getText } = useText()
const isVisible = entries.some((entry) => entry.getVisible?.(context) ?? true)
return (
return !isVisible ? null : (
<FocusArea active={focusArea} direction="vertical">
{(innerProps) => (
<div className="flex w-full flex-col gap-settings-section-header" {...innerProps}>
{!heading ? null : (
<aria.Heading level={2} className="h-[2.375rem] py-0.5 text-xl font-bold">
<Text.Heading level={2} weight="bold">
{getText(nameId)}
</aria.Heading>
</Text.Heading>
)}
<div className="flex flex-col">
{entries.map((entry, i) => (

View File

@ -223,6 +223,14 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
</div>
</aria.Cell>
</aria.Row>
: userGroups.length === 0 ?
<aria.Row className="h-row">
<aria.Cell className="col-span-2 px-2.5 placeholder">
{isAdmin ?
getText('youHaveNoUserGroupsAdmin')
: getText('youHaveNoUserGroupsNonAdmin')}
</aria.Cell>
</aria.Row>
: (userGroup) => (
<>
<UserGroupRow userGroup={userGroup} doDeleteUserGroup={doDeleteUserGroup} />

View File

@ -1,6 +1,4 @@
/** @file A row representing a user in a table of users. */
import * as React from 'react'
import Cross2 from '#/assets/cross2.svg'
import * as contextMenuHooks from '#/hooks/contextMenuHooks'
@ -90,7 +88,9 @@ export default function UserRow(props: UserRowProps) {
</div>
</aria.Cell>
<aria.Cell className="text whitespace-nowrap rounded-r-full border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:border-r-0 group-selected:bg-selected-frame">
<ariaComponents.Text nowrap truncate="1" className="block">
{user.email}
</ariaComponents.Text>
</aria.Cell>
{doDeleteUserRaw == null ?
null

View File

@ -152,6 +152,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
settingsTab: SettingsTabType.organization,
icon: PeopleSettingsIcon,
organizationOnly: true,
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
sections: [
{
nameId: 'organizationSettingsSection',
@ -285,7 +286,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
settingsTab: SettingsTabType.billingAndPlans,
icon: CreditCardIcon,
organizationOnly: true,
visible: (context) => context.organization?.subscription != null,
visible: ({ organization }) => organization?.subscription != null,
sections: [],
onPress: (context) =>
context.queryClient
@ -312,6 +313,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
settingsTab: SettingsTabType.members,
icon: PeopleIcon,
organizationOnly: true,
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
feature: 'inviteUser',
sections: [
{
@ -325,6 +327,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
settingsTab: SettingsTabType.userGroups,
icon: PeopleSettingsIcon,
organizationOnly: true,
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
feature: 'userGroups',
sections: [
{
@ -391,6 +394,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
settingsTab: SettingsTabType.activityLog,
icon: LogIcon,
organizationOnly: true,
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
sections: [
{
nameId: 'activityLogSettingsSection',
@ -470,6 +474,7 @@ export interface SettingsInputEntryData {
readonly setValue: (context: SettingsContext, value: string) => Promise<void>
readonly validate?: (value: string, context: SettingsContext) => string | true
readonly getEditable: (context: SettingsContext) => boolean
readonly getVisible?: (context: SettingsContext) => boolean
}
// ===============================

View File

@ -56,7 +56,7 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
: <div key={name} className="flex flex-col items-start">
<Header
id={`${name}_header`}
className="mb-sidebar-section-heading-b h-text px-sidebar-section-heading-x py-sidebar-section-heading-y text-[13.5px] font-bold leading-cozy"
className="z-1 mb-sidebar-section-heading-b h-text px-sidebar-section-heading-x py-sidebar-section-heading-y text-[13.5px] font-bold leading-cozy"
>
{name}
</Header>

View File

@ -86,6 +86,7 @@ export default class LocalStorage {
set<K extends LocalStorageKey>(key: K, value: LocalStorageData[K]) {
this.values[key] = value
this.eventTarget.dispatchEvent(new Event(key))
this.eventTarget.dispatchEvent(new Event('_change'))
this.save()
}
@ -96,6 +97,7 @@ export default class LocalStorage {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.values[key]
this.eventTarget.dispatchEvent(new Event(key))
this.eventTarget.dispatchEvent(new Event('_change'))
this.save()
return oldValue
}
@ -114,13 +116,23 @@ export default class LocalStorage {
key: K,
callback: (value: LocalStorageData[K] | undefined) => void,
) {
const wrapped = () => {
const value = this.values[key]
callback(value)
const onChange = () => {
callback(this.values[key])
}
this.eventTarget.addEventListener(key, wrapped)
this.eventTarget.addEventListener(key, onChange)
return () => {
this.eventTarget.removeEventListener(key, wrapped)
this.eventTarget.removeEventListener(key, onChange)
}
}
/** Add an event listener to all keys. */
subscribeAll(callback: (value: Partial<LocalStorageData>) => void) {
const onChange = () => {
callback(this.values)
}
this.eventTarget.addEventListener('_change', onChange)
return () => {
this.eventTarget.removeEventListener('_change', onChange)
}
}

View File

@ -65,6 +65,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
naming: ['"Enso Naming"', '"Enso"', '"M PLUS 1"'],
},
fontSize: {
'2xs': '10.5px',
xs: '11.5px',
sm: '13px',
xl: '19px',
@ -344,7 +345,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
},
zIndex: {
1: '1',
3: '3',
tooltip: '2',
},
backdropBlur: {
xs: '2px',

View File

@ -505,6 +505,21 @@ export interface CreateCustomerPortalSessionResponse {
readonly url: string | null
}
/** Whether the user is on a plan associated with an organization. */
export function isUserOnPlanWithOrganization(user: User) {
switch (user.plan) {
case undefined:
case Plan.free:
case Plan.solo: {
return false
}
case Plan.team:
case Plan.enterprise: {
return true
}
}
}
/** Whether an {@link AssetPermission} is a {@link UserPermission}. */
export function isUserPermission(permission: AssetPermission): permission is UserPermission {
return 'user' in permission

View File

@ -64,7 +64,7 @@
"missingVerificationCodeError": "Missing verification code",
"passwordMismatchError": "Passwords do not match.",
"passwordLengthError": "Your password must be between 6 and 256 characters long.",
"passwordValidationMessage": "Your password must include numbers, letters (both lowercase and uppercase) and symbols.",
"passwordValidationMessage": "Your password must include numbers, letters (both lowercase and uppercase) and symbols ( ^$*.[]{}()?\"!@#%&/,><':;|_~`=+-).",
"passwordValidationError": "Your password does not meet the security requirements.",
"confirmSignUpError": "Incorrect email or confirmation code.",
@ -462,6 +462,8 @@
"organizationInviteSuffix": "' is inviting you. Would you like to accept? All your assets will be moved with you to your personal space.",
"organizationInviteErrorSuffix": "' is inviting you.",
"organizationInviteErrorMessage": "Something went wrong. Please contact the administrators at",
"youHaveNoUserGroupsAdmin": "This organization has no user groups. You can create one using the button above.",
"youHaveNoUserGroupsNonAdmin": "This organization has no user groups. You can create one using the button above.",
"enableMultitabs": "Enable Multi-Tabs",
"enableMultitabsDescription": "Open multiple projects at the same time.",