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)",