Przeglądaj źródła

Merge pull request #32164 from overleaf/mj-tabs

[web] Add editor tabs experiment

GitOrigin-RevId: 8cef265a45dbd826ad8a4df7d98e38f9bb7ec1cd
Mathias Jakobsen 4 miesięcy temu
rodzic
commit
9224197fb3

+ 1 - 0
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 =>

+ 1 - 0
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": "",

+ 1 - 1
services/web/frontend/js/features/file-view/components/file-view.tsx

@@ -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) && (

+ 6 - 0
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 (
     <div className="ide-redesign-editor-container">
+      {tabsEnabled && <TabsContainer />}
       {selectedEntityCount === 0 && <NoSelectionPane />}
       {selectedEntityCount === 1 && openEntity?.type === 'fileRef' && (
         <FileView

+ 17 - 13
services/web/frontend/js/features/ide-react/context/react-context-root.tsx

@@ -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>

+ 229 - 0
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<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
+}

+ 104 - 0
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<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
+}

+ 184 - 0
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 (
+    <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>
+  )
+})

+ 25 - 0
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 (
+    <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
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<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

+ 1 - 0
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')

+ 1 - 0
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';

+ 2 - 0
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;

+ 4 - 1
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 {

+ 79 - 0
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;
+}

+ 1 - 0
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)",