mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
Merge pull request #32164 from overleaf/mj-tabs
[web] Add editor tabs experiment GitOrigin-RevId: 8cef265a45dbd826ad8a4df7d98e38f9bb7ec1cd
This commit is contained in:
committed by
Copybot
parent
52fe6ecfe2
commit
9224197fb3
@@ -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">‎{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>
|
||||
)
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user