From 9224197fb3050094514ff2fd1b56cac5cb192141 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Thu, 19 Mar 2026 11:26:17 +0000 Subject: [PATCH] Merge pull request #32164 from overleaf/mj-tabs [web] Add editor tabs experiment GitOrigin-RevId: 8cef265a45dbd826ad8a4df7d98e38f9bb7ec1cd --- .../Features/Project/ProjectController.mjs | 1 + .../web/frontend/extracted-translations.json | 1 + .../file-view/components/file-view.tsx | 2 +- .../components/editor/editor-panel.tsx | 6 + .../ide-react/context/react-context-root.tsx | 30 ++- .../ide-react/context/tabs-context.tsx | 229 ++++++++++++++++++ .../ide-react/util/disambiguate-paths.ts | 104 ++++++++ .../source-editor/components/tabs/tab.tsx | 184 ++++++++++++++ .../components/tabs/tabs-container.tsx | 25 ++ .../tabs/tabs-experiment-widget.tsx | 42 ++++ services/web/frontend/js/utils/labs-utils.ts | 1 + .../web/frontend/stylesheets/pages/all.scss | 1 + .../stylesheets/pages/editor/file-view.scss | 2 + .../pages/editor/ide-redesign.scss | 5 +- .../stylesheets/pages/editor/tabs.scss | 79 ++++++ services/web/locales/en.json | 1 + 16 files changed, 698 insertions(+), 15 deletions(-) create mode 100644 services/web/frontend/js/features/ide-react/context/tabs-context.tsx create mode 100644 services/web/frontend/js/features/ide-react/util/disambiguate-paths.ts create mode 100644 services/web/frontend/js/features/source-editor/components/tabs/tab.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/tabs/tabs-container.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/tabs/tabs-experiment-widget.tsx create mode 100644 services/web/frontend/stylesheets/pages/editor/tabs.scss diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 32e642f132..35fe842e9e 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -473,6 +473,7 @@ const _ProjectController = { 'plans-2026-phase-1', 'testing-ai-usage', 'wf-fake-non-english-suggestions', + 'tabs-experiment', ].filter(Boolean) const getUserValues = async userId => diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 3e88702941..ef8afb3641 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -299,6 +299,7 @@ "clone_with_git": "", "close": "", "close_dialog": "", + "close_tab": "", "clsi_maintenance": "", "clsi_unavailable": "", "code_check_failed_explanation": "", diff --git a/services/web/frontend/js/features/file-view/components/file-view.tsx b/services/web/frontend/js/features/file-view/components/file-view.tsx index b84ee383eb..172efd773c 100644 --- a/services/web/frontend/js/features/file-view/components/file-view.tsx +++ b/services/web/frontend/js/features/file-view/components/file-view.tsx @@ -60,7 +60,7 @@ export default function FileView({ file }: { file: BinaryFile }) { ) return ( -
+
{!hasError && content} {!isUnpreviewableFile && contentLoading && } {(isUnpreviewableFile || hasError) && ( diff --git a/services/web/frontend/js/features/ide-react/components/editor/editor-panel.tsx b/services/web/frontend/js/features/ide-react/components/editor/editor-panel.tsx index 55037d070c..80b4678ff2 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/editor-panel.tsx +++ b/services/web/frontend/js/features/ide-react/components/editor/editor-panel.tsx @@ -4,12 +4,18 @@ import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-o import FileView from '@/features/file-view/components/file-view' import { fileViewFile } from '@/features/ide-react/util/file-view' import MultipleSelectionPane from '@/features/ide-react/components/editor/multiple-selection-pane' +import { TabsContainer } from '@/features/source-editor/components/tabs/tabs-container' +import { isInExperiment } from '@/utils/labs-utils' +import { isSplitTestEnabled } from '@/utils/splitTestUtils' export default function EditorPanel() { const { selectedEntityCount, openEntity } = useFileTreeOpenContext() + const tabsEnabled = + isInExperiment('editor-tabs') && isSplitTestEnabled('tabs-experiment') return (
+ {tabsEnabled && } {selectedEntityCount === 0 && } {selectedEntityCount === 1 && openEntity?.type === 'fileRef' && ( - - - - - - { - childrenWrappedWithDynamicProviders - } - - - - - + + + + + + + { + childrenWrappedWithDynamicProviders + } + + + + + + diff --git a/services/web/frontend/js/features/ide-react/context/tabs-context.tsx b/services/web/frontend/js/features/ide-react/context/tabs-context.tsx new file mode 100644 index 0000000000..7e62256bb4 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/context/tabs-context.tsx @@ -0,0 +1,229 @@ +import { findInTree } from '@/features/file-tree/util/find-in-tree' +import { useFileTreeData } from '@/shared/context/file-tree-data-context' +import { useProjectContext } from '@/shared/context/project-context' +import usePersistedState from '@/shared/hooks/use-persisted-state' +import React, { FC, useCallback, useContext, useEffect, useMemo } from 'react' +import { useFileTreeOpenContext } from './file-tree-open-context' +import { useEditorManagerContext } from './editor-manager-context' +import { debugConsole } from '@/utils/debugging' +import { isInExperiment } from '@/utils/labs-utils' +import { disambiguatePaths } from '../util/disambiguate-paths' +import { isSplitTestEnabled } from '@/utils/splitTestUtils' + +type PersistedTabInfo = { id: string; lifetime: Lifetime } + +type Lifetime = 'permanent' | 'temporary' + +export type EditorFileTab = { + id: string + displayPath: string + lifetime: Lifetime +} + +const TabsContext = React.createContext< + | { + tabs: EditorFileTab[] + openTab: (id: string) => void + closeTab: (id: string) => void + makeTabPermanent: (id: string) => void + moveTab: ( + sourceTabId: string, + targetTabId: string, + position: 'left' | 'right' + ) => void + } + | undefined +>(undefined) + +export const TabsProvider: FC = ({ children }) => { + const { projectId } = useProjectContext() + const { fileTreeData } = useFileTreeData() + + const { openEntity } = useFileTreeOpenContext() + const { openDocWithId, openFileWithId } = useEditorManagerContext() + const tabsEnabled = + isInExperiment('editor-tabs') && isSplitTestEnabled('tabs-experiment') + + const [openTabs, setOpenTabs] = usePersistedState( + `open-tabs:${projectId}`, + [] + ) + + const tabs = useMemo(() => { + if (!tabsEnabled) { + return [] + } + if (!fileTreeData) { + return [] + } + const tabsFileTreeLookup = openTabs + .map(tab => ({ + lifetime: tab.lifetime, + result: findInTree(fileTreeData, tab.id), + })) + .filter(x => !!x.result) + + const pathLookup = disambiguatePaths( + tabsFileTreeLookup.map(tab => tab.result!), + fileTreeData + ) + + return tabsFileTreeLookup.map(tab => ({ + id: tab.result!.entity._id, + displayPath: + pathLookup.get(tab.result!.entity._id) || tab.result!.entity.name, + lifetime: tab.lifetime, + })) + }, [fileTreeData, openTabs, tabsEnabled]) + + const openTab = useCallback( + async (id: string) => { + if (!fileTreeData) { + return + } + const file = findInTree(fileTreeData, id) + if (!file) { + return + } + if (file.type === 'doc') { + await openDocWithId(file.entity._id) + } else if (file.type === 'fileRef') { + openFileWithId(file.entity._id) + } else { + debugConsole.error('Attempting to open invalid entity type') + } + }, + [fileTreeData, openDocWithId, openFileWithId] + ) + + const closeTab = useCallback( + async (id: string) => { + if (openTabs.length <= 1) { + // Can't close last file + return + } + + if (id === openEntity?.entity._id) { + const currentIndex = openTabs.findIndex(tab => tab.id === id) + if (currentIndex === -1) { + debugConsole.warn('Attempting to close tab that is not open') + return + } + + const nextTab = openTabs[currentIndex + 1] || openTabs[currentIndex - 1] + if (!nextTab) { + debugConsole.warn('No next tab to switch to on close') + return + } + + await openTab(nextTab.id) + } + + setOpenTabs(current => current.filter(tab => tab.id !== id)) + }, + [openTabs, openEntity, setOpenTabs, openTab] + ) + + const moveTab = useCallback( + (sourceTabId: string, targetTabId: string, position: 'left' | 'right') => { + debugConsole.log({ sourceTabId, targetTabId, position }) + if (sourceTabId === targetTabId) { + debugConsole.debug( + 'Source and target tab ids are the same for moving tab' + ) + return + } + setOpenTabs(current => { + const sourceTabIndex = current.findIndex(tab => tab.id === sourceTabId) + const targetTabIndex = current.findIndex(tab => tab.id === targetTabId) + if (sourceTabIndex === -1 || targetTabIndex === -1) { + debugConsole.warn('Invalid tab ids for moving tab') + return current + } + + if ( + (position === 'right' && targetTabIndex === sourceTabIndex - 1) || + (position === 'left' && targetTabIndex === sourceTabIndex + 1) + ) { + debugConsole.debug( + 'Source and target tab are already adjacent for move' + ) + return current + } + + return arrayMove(current, sourceTabIndex, targetTabIndex, position) + }) + }, + [setOpenTabs] + ) + + const makeTabPermanent = useCallback( + (id: string) => { + setOpenTabs(current => + current.map(tab => + tab.id === id ? { ...tab, lifetime: 'permanent' } : tab + ) + ) + }, + [setOpenTabs] + ) + + useEffect(() => { + if (!tabsEnabled) { + return + } + + if (!openEntity) { + return + } + + setOpenTabs(current => { + if (current.find(t => t.id === openEntity?.entity._id)) { + return current + } + return [ + ...current.filter(tab => tab.lifetime !== 'temporary'), + { id: openEntity.entity._id, lifetime: 'temporary' }, + ] + }) + }, [openEntity, setOpenTabs, tabsEnabled]) + + const value = useMemo( + () => ({ tabs, openTab, closeTab, moveTab, makeTabPermanent }), + [tabs, openTab, closeTab, moveTab, makeTabPermanent] + ) + + return {children} +} + +export const useTabsContext = () => { + const value = useContext(TabsContext) + if (!value) { + throw new Error('useTabsContext can only be used inside TabsProvider') + } + return value +} + +function arrayMove( + array: T[], + sourceIndex: number, + targetIndex: number, + side: 'left' | 'right' +): T[] { + const result = [...array] + + const [movedItem] = result.splice(sourceIndex, 1) + + let newIndex = targetIndex + if (sourceIndex < targetIndex) { + newIndex -= 1 + } + + if (side === 'right') { + newIndex += 1 + } + + result.splice(newIndex, 0, movedItem) + + return result +} diff --git a/services/web/frontend/js/features/ide-react/util/disambiguate-paths.ts b/services/web/frontend/js/features/ide-react/util/disambiguate-paths.ts new file mode 100644 index 0000000000..ea976181d6 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/util/disambiguate-paths.ts @@ -0,0 +1,104 @@ +import { findInTree } from '@/features/file-tree/util/find-in-tree' +import { Folder } from '@ol-types/folder' +import { FileTreeFindResult } from '../types/file-tree' +type Id = string + +type PartialTabInfo = { + id: Id + name: string + path: Id[] + depth: number +} + +const lookupNameInTreeWithCache = ( + fileTree: Folder, + id: Id, + cache: Map +) => { + if (id === fileTree._id) return '' // Special case for root + + // Check cache first to avoid expensive tree traversal on every call + const cached = cache.get(id) + if (cached !== undefined) { + return cached + } + + // Finally look it up if we haven't already seen the id + const result = findInTree(fileTree, id) + const name = result?.entity.name ?? '' + cache.set(id, name) + return name +} + +function getDisplayPath( + tab: PartialTabInfo, + fileTree: Folder, + cache: Map +): string { + return ( + tab.path + // filter out the first part of the path (root folder) + .slice(Math.max(tab.path.length - tab.depth, 1), tab.path.length) + .map(parentFolderId => + lookupNameInTreeWithCache(fileTree, parentFolderId, cache) + ) + .concat(tab.name) + .join('/') + ) +} + +export const disambiguatePaths = ( + tabs: FileTreeFindResult[], + fileTree: Folder +): Map => { + const cache = new Map() + const results = new Map() + + const tabsWithPartialPaths: PartialTabInfo[] = tabs.map(tab => ({ + id: tab.entity._id, + name: tab.entity.name, + path: tab.path, + depth: 0, + })) + + while (true) { + const collisionMap = new Map() + for (const tab of tabsWithPartialPaths) { + // already resolved + if (results.has(tab.id)) { + continue + } + const displayPath = getDisplayPath(tab, fileTree, cache) + if (!collisionMap.has(displayPath)) { + collisionMap.set(displayPath, []) + } + collisionMap.get(displayPath)!.push(tab) + } + + let hasCollisions = false + for (const [displayPath, collidingTabs] of collisionMap.entries()) { + if (collidingTabs.length === 1) { + const tab = collidingTabs[0] + results.set(tab.id, displayPath) + } else { + for (const tab of collidingTabs) { + if (tab.depth < tab.path.length - 1) { + // This tab is still colliding, but we can reveal more of the path + // (minus root folder) to try to disambiguate it + tab.depth++ + hasCollisions = true + } else { + // We've already revealed the entire path and they're still + // colliding, so we'll just have to use the name + results.set(tab.id, tab.name) + } + } + } + } + if (!hasCollisions) { + break + } + } + + return results +} diff --git a/services/web/frontend/js/features/source-editor/components/tabs/tab.tsx b/services/web/frontend/js/features/source-editor/components/tabs/tab.tsx new file mode 100644 index 0000000000..586e629634 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/tabs/tab.tsx @@ -0,0 +1,184 @@ +import { EditorFileTab } from '@/features/ide-react/context/tabs-context' +import MaterialIcon from '@/shared/components/material-icon' +import { debugConsole } from '@/utils/debugging' +import classNames from 'classnames' +import { throttle } from 'lodash' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type TabProps = { + tab: EditorFileTab + openTab: (id: string) => void + closeTab: (id: string) => void + makeTabPermanent: (id: string) => void + isSelected: boolean + onTabDrop: ( + sourceTabId: string, + targetTabId: string, + position: 'left' | 'right' + ) => void +} + +function getSideOfTargetFromEvent( + element: Element, + clientX: number +): 'left' | 'right' { + const rect = element.getBoundingClientRect() + if (rect.left + rect.width / 2 < clientX) { + return 'right' + } else { + return 'left' + } +} + +const TAB_TRANSFER_TYPE = 'text/x.tab-id' + +export const Tab = memo(function Tab({ + tab, + openTab, + closeTab, + makeTabPermanent, + isSelected, + onTabDrop, +}: TabProps) { + const { t } = useTranslation() + + const [dropTargetPosition, setDropTargetPosition] = useState< + 'left' | 'right' | null + >(null) + const onDragStart = useCallback( + (e: React.DragEvent) => { + e.dataTransfer.setData(TAB_TRANSFER_TYPE, tab.id) + e.dataTransfer.effectAllowed = 'move' + }, + [tab] + ) + + const throttledOnDragOver = useMemo( + () => + throttle((element: Element, clientX: number) => { + setDropTargetPosition(getSideOfTargetFromEvent(element, clientX)) + }, 50), + [] + ) + + const onDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + throttledOnDragOver(e.currentTarget, e.clientX) + }, + [throttledOnDragOver] + ) + + const onDragLeave = useCallback(() => { + throttledOnDragOver.cancel() + setDropTargetPosition(null) + }, [throttledOnDragOver]) + + const onDrop = useCallback( + (e: React.DragEvent) => { + throttledOnDragOver.cancel() + setDropTargetPosition(null) + e.preventDefault() + const draggedTabId = e.dataTransfer.getData(TAB_TRANSFER_TYPE) + if (!draggedTabId) { + debugConsole.warn('No dragged tab id found in dataTransfer') + return + } + onTabDrop( + draggedTabId, + tab.id, + getSideOfTargetFromEvent(e.currentTarget, e.clientX) + ) + }, + [onTabDrop, tab, throttledOnDragOver] + ) + + const onDoubleClick = useCallback(() => { + makeTabPermanent(tab.id) + }, [makeTabPermanent, tab]) + + const onClick = useCallback( + (e: React.MouseEvent) => { + if (e.button === 0) { + openTab(tab.id) + } + }, + [openTab, tab] + ) + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.code === 'Enter') { + openTab(tab.id) + } + }, + [openTab, tab] + ) + + const onCloseClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + closeTab(tab.id) + }, + [closeTab, tab] + ) + + const onMouseUp = useCallback( + (e: React.MouseEvent) => { + if (e.button === 1) { + e.stopPropagation() + closeTab(tab.id) + } + }, + [closeTab, tab] + ) + + useEffect(() => { + if (isSelected && tab.lifetime === 'temporary') { + const handler = () => { + makeTabPermanent(tab.id) + } + document.body.addEventListener('keydown', handler) + return () => { + document.body.removeEventListener('keydown', handler) + } + } + }, [isSelected, makeTabPermanent, tab]) + + return ( +
+
‎{tab.displayPath}
+
+ +
+
+ ) +}) diff --git a/services/web/frontend/js/features/source-editor/components/tabs/tabs-container.tsx b/services/web/frontend/js/features/source-editor/components/tabs/tabs-container.tsx new file mode 100644 index 0000000000..21f86380e2 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/tabs/tabs-container.tsx @@ -0,0 +1,25 @@ +import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context' +import { useTabsContext } from '@/features/ide-react/context/tabs-context' +import { Tab } from './tab' + +export const TabsContainer = () => { + const { tabs, openTab, closeTab, moveTab, makeTabPermanent } = + useTabsContext() + const { openEntity } = useFileTreeOpenContext() + + return ( +
+ {tabs.map(tab => ( + + ))} +
+ ) +} diff --git a/services/web/frontend/js/features/source-editor/components/tabs/tabs-experiment-widget.tsx b/services/web/frontend/js/features/source-editor/components/tabs/tabs-experiment-widget.tsx new file mode 100644 index 0000000000..dbbc9784bf --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/tabs/tabs-experiment-widget.tsx @@ -0,0 +1,42 @@ +import LabsExperimentWidget, { + LabsExperimentWidgetProps, +} from '@/shared/components/labs/labs-experiments-widget' +import { isInExperiment } from '@/utils/labs-utils' +import { useState } from 'react' +import MaterialIcon from '@/shared/components/material-icon' +import { isSplitTestEnabled } from '@/utils/splitTestUtils' + +type LabsWidgetProps = Pick & { + labsProgram: boolean +} + +const LabsWidget = ({ setErrorMessage, labsProgram }: LabsWidgetProps) => { + const [optedIn, setOptedIn] = useState(isInExperiment('editor-tabs')) + + const description = ( + Quickly switch between open files and documents using tabs. + ) + + return ( + + } + optedInDescription={description} + labsEnabled={labsProgram} + /> + ) +} + +export const hidden = () => !isSplitTestEnabled('tabs-experiment') + +export default LabsWidget diff --git a/services/web/frontend/js/utils/labs-utils.ts b/services/web/frontend/js/utils/labs-utils.ts index 7de15b7a7d..5ec880726f 100644 --- a/services/web/frontend/js/utils/labs-utils.ts +++ b/services/web/frontend/js/utils/labs-utils.ts @@ -7,6 +7,7 @@ export type ActiveExperiment = | 'monthly-texlive' | 'bibtex-visual-editor' | 'overleaf-code' + | 'editor-tabs' export const isInExperiment = (experiment: ActiveExperiment): boolean => { const experiments = getMeta('ol-labsExperiments') diff --git a/services/web/frontend/stylesheets/pages/all.scss b/services/web/frontend/stylesheets/pages/all.scss index 39c11aa770..e8aea950f0 100644 --- a/services/web/frontend/stylesheets/pages/all.scss +++ b/services/web/frontend/stylesheets/pages/all.scss @@ -29,6 +29,7 @@ @import 'editor/compile-button'; @import 'editor/publish-modal'; @import 'editor/share'; +@import 'editor/tabs'; @import 'editor/tags-input'; @import 'editor/review-panel'; @import 'editor/table-generator-column-width-modal'; diff --git a/services/web/frontend/stylesheets/pages/editor/file-view.scss b/services/web/frontend/stylesheets/pages/editor/file-view.scss index f70ed72a04..0ce61da45f 100644 --- a/services/web/frontend/stylesheets/pages/editor/file-view.scss +++ b/services/web/frontend/stylesheets/pages/editor/file-view.scss @@ -18,6 +18,8 @@ padding: var(--spacing-05); text-align: center; overflow: auto; + flex: 1 1 auto; + position: relative; .loading-panel { padding-top: 8rem; diff --git a/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss b/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss index f684b867ca..65c018b078 100644 --- a/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss +++ b/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss @@ -48,10 +48,13 @@ .ide-redesign-editor-container { position: relative; height: 100%; + display: flex; + flex-direction: column; } .ide-redesign-editor-content { - height: 100%; + flex: 1 1 auto; + position: relative; } .ide-redesign-python-editor-split { diff --git a/services/web/frontend/stylesheets/pages/editor/tabs.scss b/services/web/frontend/stylesheets/pages/editor/tabs.scss new file mode 100644 index 0000000000..7db6d2b5cd --- /dev/null +++ b/services/web/frontend/stylesheets/pages/editor/tabs.scss @@ -0,0 +1,79 @@ +:root { + --editor-tab-max-path-length: 150px; +} + +.editor-tabs-container { + display: flex; + flex-direction: row; + list-style: none; + padding: 0; + border-bottom: 1px solid var(--border-divider-themed); + margin: 0; + flex: 0 0 auto; + background: var(--bg-primary-themed); + overflow-x: auto; +} + +.editor-file-tab { + --tab-bg-color: transparent; + + display: inline-flex; + align-items: center; + gap: var(--spacing-02); + justify-content: center; + box-sizing: border-box; + padding: var(--spacing-02) var(--spacing-03); + background: var(--tab-bg-color); + outline: none; + color: var(--content-primary-themed); + border: none; + font-size: var(--font-size-02); + flex-direction: row; + cursor: pointer; + border-right: 1px solid var(--border-divider-themed); + border-left: 1px solid var(--tab-bg-color); + user-select: none; + + &.tab-selected { + --tab-bg-color: var(--bg-secondary-themed); + } + + .material-symbols { + vertical-align: middle; + font-size: var(--font-size-03); + } + + &.tab-drop-right { + border-right-color: var(--border-primary-themed); + } + + &.tab-drop-left { + border-left-color: var(--border-primary-themed); + } + + &.tab-temporary { + font-style: italic; + } + + .editor-file-tab-close-action { + background: transparent; + border: none; + color: var(--content-primary-themed); + cursor: pointer; + margin: 0; + padding: 0; + + &:hover { + color: var(--content-secondary-themed); + } + } +} + +.editor-file-tab-path { + text-wrap: nowrap; + max-width: var(--editor-tab-max-path-length); + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + overflow: hidden; +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index b0837feef0..52274069fe 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -376,6 +376,7 @@ "clone_with_git": "Clone with Git", "close": "Close", "close_dialog": "Close dialog", + "close_tab": "Close tab", "clsi_maintenance": "The compile servers are down for maintenance, and will be back shortly.", "clsi_unavailable": "Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.", "cn": "Chinese (Simplified)",