Merge pull request #32164 from overleaf/mj-tabs

[web] Add editor tabs experiment

GitOrigin-RevId: 8cef265a45dbd826ad8a4df7d98e38f9bb7ec1cd
This commit is contained in:
Mathias Jakobsen
2026-03-20 09:06:32 +00:00
committed by Copybot
parent 52fe6ecfe2
commit 9224197fb3
16 changed files with 698 additions and 15 deletions
@@ -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 =>
@@ -299,6 +299,7 @@
"clone_with_git": "",
"close": "",
"close_dialog": "",
"close_tab": "",
"clsi_maintenance": "",
"clsi_unavailable": "",
"code_check_failed_explanation": "",
@@ -60,7 +60,7 @@ export default function FileView({ file }: { file: BinaryFile }) {
)
return (
<div className="file-view full-size">
<div className="file-view">
{!hasError && content}
{!isUnpreviewableFile && contentLoading && <FileViewLoadingIndicator />}
{(isUnpreviewableFile || hasError) && (
@@ -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 (
<div className="ide-redesign-editor-container">
{tabsEnabled && <TabsContainer />}
{selectedEntityCount === 0 && <NoSelectionPane />}
{selectedEntityCount === 1 && openEntity?.type === 'fileRef' && (
<FileView
@@ -32,6 +32,7 @@ import { CommandRegistryProvider } from './command-registry-context'
import { EditorSelectionProvider } from '@/shared/context/editor-selection-context'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { TutorialProvider } from '@/shared/context/tutorial-context'
import { TabsProvider } from './tabs-context'
const rootContextProviders = importOverleafModules('rootContextProviders') as {
import: { default: ElementType }
@@ -76,6 +77,7 @@ export const ReactContextRoot: FC<
UserFeaturesProvider,
EditorSelectionProvider,
TutorialProvider,
TabsProvider,
...providers,
}
@@ -119,19 +121,21 @@ export const ReactContextRoot: FC<
<Providers.DetachCompileProvider>
<Providers.ChatProvider>
<Providers.FileTreeOpenProvider>
<Providers.OnlineUsersProvider>
<Providers.MetadataProvider>
<Providers.OutlineProvider>
<Providers.CommandRegistryProvider>
<Providers.EditorSelectionProvider>
{
childrenWrappedWithDynamicProviders
}
</Providers.EditorSelectionProvider>
</Providers.CommandRegistryProvider>
</Providers.OutlineProvider>
</Providers.MetadataProvider>
</Providers.OnlineUsersProvider>
<Providers.TabsProvider>
<Providers.OnlineUsersProvider>
<Providers.MetadataProvider>
<Providers.OutlineProvider>
<Providers.CommandRegistryProvider>
<Providers.EditorSelectionProvider>
{
childrenWrappedWithDynamicProviders
}
</Providers.EditorSelectionProvider>
</Providers.CommandRegistryProvider>
</Providers.OutlineProvider>
</Providers.MetadataProvider>
</Providers.OnlineUsersProvider>
</Providers.TabsProvider>
</Providers.FileTreeOpenProvider>
</Providers.ChatProvider>
</Providers.DetachCompileProvider>
@@ -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<React.PropsWithChildren> = ({ 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<PersistedTabInfo[]>(
`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 <TabsContext.Provider value={value}>{children}</TabsContext.Provider>
}
export const useTabsContext = () => {
const value = useContext(TabsContext)
if (!value) {
throw new Error('useTabsContext can only be used inside TabsProvider')
}
return value
}
function arrayMove<T>(
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
}
@@ -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<Id, string>
) => {
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<Id, string>
): 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<Id, string> => {
const cache = new Map<Id, string>()
const results = new Map<Id, string>()
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<string, PartialTabInfo[]>()
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
}
@@ -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 (
<div
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
draggable
onDrop={onDrop}
onDoubleClick={onDoubleClick}
role="tab"
aria-selected={isSelected}
key={tab.id}
onClick={onClick}
onMouseUp={onMouseUp}
onKeyDown={onKeyDown}
tabIndex={0}
className={classNames('editor-file-tab', {
'tab-selected': isSelected,
'tab-drop-left': dropTargetPosition === 'left',
'tab-drop-right': dropTargetPosition === 'right',
'tab-temporary': tab.lifetime === 'temporary',
})}
>
<div className="editor-file-tab-path">&lrm;{tab.displayPath}</div>
<div className="editor-file-tab-action">
<button
onClick={onCloseClick}
className="editor-file-tab-close-action"
aria-label={t('close_tab')}
>
<MaterialIcon type="close" />
</button>
</div>
</div>
)
})
@@ -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 (
<div className="editor-tabs-container" role="tablist">
{tabs.map(tab => (
<Tab
key={tab.id}
tab={tab}
openTab={openTab}
closeTab={closeTab}
isSelected={openEntity?.entity._id === tab.id}
onTabDrop={moveTab}
makeTabPermanent={makeTabPermanent}
/>
))}
</div>
)
}
@@ -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<LabsExperimentWidgetProps, 'setErrorMessage'> & {
labsProgram: boolean
}
const LabsWidget = ({ setErrorMessage, labsProgram }: LabsWidgetProps) => {
const [optedIn, setOptedIn] = useState(isInExperiment('editor-tabs'))
const description = (
<span>Quickly switch between open files and documents using tabs.</span>
)
return (
<LabsExperimentWidget
description={description}
experimentName="editor-tabs"
title="Editor tabs"
setOptedIn={setOptedIn}
setErrorMessage={setErrorMessage}
optedIn={optedIn}
logo={
<MaterialIcon
type="tab_group"
className="rounded bg-primary-subtle labs-experiment-icon"
/>
}
optedInDescription={description}
labsEnabled={labsProgram}
/>
)
}
export const hidden = () => !isSplitTestEnabled('tabs-experiment')
export default LabsWidget
@@ -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')
@@ -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';
@@ -18,6 +18,8 @@
padding: var(--spacing-05);
text-align: center;
overflow: auto;
flex: 1 1 auto;
position: relative;
.loading-panel {
padding-top: 8rem;
@@ -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 {
@@ -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;
}
+1
View File
@@ -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)",