tabs.spec.tsx 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072
  1. import React, { FC, useEffect, useRef, useState } from 'react'
  2. import { EditorProviders } from '../../../helpers/editor-providers'
  3. import { TabsContainer } from '../../../../../frontend/js/features/source-editor/components/tabs/tabs-container'
  4. import {
  5. FileTreeDocumentFindResult,
  6. FileTreeFileRefFindResult,
  7. } from '@/features/ide-react/types/file-tree'
  8. import {
  9. EditorManager,
  10. EditorManagerContext,
  11. } from '@/features/ide-react/context/editor-manager-context'
  12. import { TAB_TRANSFER_TYPE } from '@/features/ide-react/context/tabs-context'
  13. import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
  14. import {
  15. EditorViewContext,
  16. useEditorViewContext,
  17. } from '@/features/ide-react/context/editor-view-context'
  18. import { EditorView } from '@codemirror/view'
  19. import { EditorState, Transaction } from '@codemirror/state'
  20. import { tabsListener } from '@/features/source-editor/extensions/tabs-listener'
  21. const DOC_IDS = {
  22. main: 'doc-main-id',
  23. intro: 'doc-intro-id',
  24. appendix: 'doc-appendix-id',
  25. bibFile: 'file-bib-id',
  26. introA: 'doc-main-a',
  27. introB: 'doc-main-b',
  28. }
  29. const FOLDER_IDS = {
  30. chapterA: 'folder-chapter-a',
  31. chapterB: 'folder-chapter-b',
  32. }
  33. const DOC_NAMES: Record<string, string> = {
  34. [DOC_IDS.main]: 'main.tex',
  35. [DOC_IDS.intro]: 'intro.tex',
  36. [DOC_IDS.appendix]: 'appendix.tex',
  37. [DOC_IDS.bibFile]: 'refs.bib',
  38. [DOC_IDS.introA]: 'intro.tex',
  39. [DOC_IDS.introB]: 'intro.tex',
  40. }
  41. function makeRootFolder(
  42. docs: { _id: string; name: string }[] = [],
  43. folders: any[] = [],
  44. fileRefs: { _id: string; name: string }[] = []
  45. ) {
  46. return [
  47. {
  48. _id: 'root-folder-id',
  49. name: 'rootFolder',
  50. docs,
  51. folders,
  52. fileRefs,
  53. },
  54. ]
  55. }
  56. const defaultDocs = [
  57. { _id: DOC_IDS.main, name: 'main.tex' },
  58. { _id: DOC_IDS.intro, name: 'intro.tex' },
  59. { _id: DOC_IDS.appendix, name: 'appendix.tex' },
  60. ]
  61. const defaultRootFolder = makeRootFolder(defaultDocs)
  62. function makeDocEntity(
  63. id: string,
  64. name: string,
  65. path: string[] = ['root-folder-id']
  66. ): FileTreeDocumentFindResult {
  67. return {
  68. type: 'doc',
  69. entity: { _id: id, name },
  70. parent: [],
  71. parentFolderId: path[path.length - 1],
  72. path,
  73. index: 0,
  74. }
  75. }
  76. function makeFileRefEntity(
  77. id: string,
  78. name: string
  79. ): FileTreeFileRefFindResult {
  80. return {
  81. type: 'fileRef',
  82. entity: {
  83. _id: id,
  84. name,
  85. linkedFileData: undefined,
  86. created: new Date().toISOString(),
  87. hash: 'abc123',
  88. },
  89. parent: [],
  90. parentFolderId: 'root-folder-id',
  91. path: ['root-folder-id'],
  92. index: 0,
  93. }
  94. }
  95. function makeEditorManagerProvider() {
  96. const EditorManagerProvider: FC<React.PropsWithChildren> = ({ children }) => {
  97. const value = {
  98. getEditorType: () => null,
  99. getCurrentDocValue: () => null,
  100. getCurrentDocumentId: () => null,
  101. setIgnoringExternalUpdates: () => {},
  102. openDocWithId: cy.stub().as('openDocWithId').resolves(),
  103. openDoc: cy.stub().as('openDoc').resolves(),
  104. openDocs: { awaitBufferedOps: cy.stub().resolves() } as any,
  105. openFileWithId: cy.stub().as('openFileWithId'),
  106. openInitialDoc: cy.stub().resolves(),
  107. isLoading: false,
  108. jumpToLine: () => {},
  109. debugTimers: { current: {} },
  110. } as unknown as EditorManager
  111. return (
  112. <EditorManagerContext.Provider value={value}>
  113. {children}
  114. </EditorManagerContext.Provider>
  115. )
  116. }
  117. return EditorManagerProvider
  118. }
  119. function makeEditorViewProvider() {
  120. const EditorViewProvider: FC<React.PropsWithChildren> = ({ children }) => {
  121. const parentRef = useRef<HTMLDivElement>(null)
  122. const [view, setView] = useState<EditorView | null>(null)
  123. useEffect(() => {
  124. if (!parentRef.current) return
  125. const editorView = new EditorView({
  126. state: EditorState.create({
  127. extensions: [
  128. tabsListener(),
  129. EditorView.contentAttributes.of({
  130. 'data-testid': 'mock-editor-view',
  131. }),
  132. ],
  133. }),
  134. parent: parentRef.current,
  135. })
  136. setView(editorView)
  137. return () => editorView.destroy()
  138. }, [])
  139. return (
  140. <EditorViewContext.Provider value={{ view, setView: () => {} }}>
  141. {children}
  142. <div ref={parentRef} />
  143. </EditorViewContext.Provider>
  144. )
  145. }
  146. return EditorViewProvider
  147. }
  148. function RemoteChangeButton() {
  149. const { view } = useEditorViewContext()
  150. return (
  151. <button
  152. type="button"
  153. onClick={() =>
  154. view?.dispatch({
  155. changes: { from: 0, insert: 'remote text' },
  156. annotations: Transaction.remote.of(true),
  157. })
  158. }
  159. >
  160. Add a remote change
  161. </button>
  162. )
  163. }
  164. // Rendered inside the provider tree to call handleFileTreeSelect() when a
  165. // custom DOM event fires. Also triggers handleFileTreeInit() on mount.
  166. function FileSelectionDriver({
  167. autoSelectEntity,
  168. }: {
  169. autoSelectEntity?: FileTreeDocumentFindResult | FileTreeFileRefFindResult
  170. } = {}) {
  171. const { handleFileTreeSelect, handleFileTreeInit } = useFileTreeOpenContext()
  172. const initDone = useRef(false)
  173. useEffect(() => {
  174. if (!initDone.current) {
  175. initDone.current = true
  176. handleFileTreeInit()
  177. if (autoSelectEntity) {
  178. handleFileTreeSelect([autoSelectEntity])
  179. }
  180. }
  181. }, [handleFileTreeInit, handleFileTreeSelect, autoSelectEntity])
  182. useEffect(() => {
  183. const handler = (e: Event) => {
  184. const { detail } = e as CustomEvent
  185. handleFileTreeSelect([detail])
  186. }
  187. document.addEventListener('test:selectEntity', handler)
  188. return () => {
  189. document.removeEventListener('test:selectEntity', handler)
  190. }
  191. }, [handleFileTreeSelect])
  192. return null
  193. }
  194. // Dispatch a custom event to select an entity (simulates file-tree click).
  195. // Must be wrapped in cy.then() so it runs in the Cypress command queue.
  196. function selectEntity(
  197. entity: FileTreeDocumentFindResult | FileTreeFileRefFindResult
  198. ) {
  199. document.dispatchEvent(
  200. new CustomEvent('test:selectEntity', { detail: entity })
  201. )
  202. cy.findByRole('tab', { name: new RegExp(entity.entity.name) }).should('exist')
  203. }
  204. function selectDoc(id: string, path?: string[]) {
  205. selectEntity(makeDocEntity(id, DOC_NAMES[id], path))
  206. }
  207. function enableEditorTabs() {
  208. cy.window().then(win => {
  209. win.metaAttributesCache.set('ol-splitTestVariants', {
  210. 'editor-tabs': 'enabled',
  211. })
  212. win.metaAttributesCache.set('ol-labsExperiments', ['editor-tabs'])
  213. })
  214. }
  215. describe('File Tabs', function () {
  216. function mountTabs(options?: { rootFolder?: any; userSettings?: any }) {
  217. const rootFolder = options?.rootFolder ?? defaultRootFolder
  218. cy.mount(
  219. <EditorProviders
  220. rootFolder={rootFolder as any}
  221. rootDocId={DOC_IDS.main}
  222. userSettings={options?.userSettings}
  223. providers={{
  224. EditorManagerProvider: makeEditorManagerProvider(),
  225. EditorViewProvider: makeEditorViewProvider(),
  226. }}
  227. >
  228. <FileSelectionDriver />
  229. <TabsContainer />
  230. <RemoteChangeButton />
  231. </EditorProviders>
  232. )
  233. }
  234. beforeEach(function () {
  235. window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
  236. cy.interceptEvents()
  237. cy.interceptTutorials()
  238. cy.interceptCompile()
  239. enableEditorTabs()
  240. // Clear persisted tab state from localStorage
  241. cy.window().then(win => {
  242. Object.keys(win.localStorage).forEach(key => {
  243. if (key.startsWith('open-tabs:')) {
  244. win.localStorage.removeItem(key)
  245. }
  246. })
  247. })
  248. cy.window().then(win => {
  249. win.metaAttributesCache.set('ol-user', { id: 'user1' })
  250. })
  251. mountTabs()
  252. cy.findByTestId('mock-editor-view').as('editorView')
  253. })
  254. describe('Initial file selection', function () {
  255. it('automatically creates a tab for the initially opened file', function () {
  256. // Re-mount with auto-selection of the root doc to simulate
  257. // the file tree opening the initial document on startup
  258. cy.mount(
  259. <EditorProviders
  260. rootFolder={defaultRootFolder as any}
  261. rootDocId={DOC_IDS.main}
  262. providers={{
  263. EditorManagerProvider: makeEditorManagerProvider(),
  264. EditorViewProvider: makeEditorViewProvider(),
  265. }}
  266. >
  267. <FileSelectionDriver
  268. autoSelectEntity={makeDocEntity(
  269. DOC_IDS.intro,
  270. DOC_NAMES[DOC_IDS.intro]
  271. )}
  272. />
  273. <TabsContainer />
  274. </EditorProviders>
  275. )
  276. cy.findByRole('tab', { name: /intro\.tex/ }).should('exist')
  277. })
  278. })
  279. describe('Tab display', function () {
  280. it('displays a tab when a file is selected', function () {
  281. cy.then(() => selectDoc(DOC_IDS.main))
  282. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  283. })
  284. it('marks the selected tab as active', function () {
  285. cy.then(() => selectDoc(DOC_IDS.main))
  286. cy.findByRole('tab', { name: /main\.tex/ })
  287. .should('have.attr', 'aria-selected', 'true')
  288. .and('have.class', 'tab-selected')
  289. })
  290. })
  291. describe('Opening tabs on file change', function () {
  292. it('opens a new tab when a different file is selected', function () {
  293. cy.then(() => selectDoc(DOC_IDS.main))
  294. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  295. // Make main permanent (keypress) so selecting another file doesn't replace it
  296. cy.get('@editorView').type('a')
  297. // Select another file
  298. cy.then(() => selectDoc(DOC_IDS.intro))
  299. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  300. cy.findByRole('tab', { name: /intro\.tex/ }).should('exist')
  301. })
  302. it('does not duplicate a tab when the same file is selected again', function () {
  303. cy.then(() => selectDoc(DOC_IDS.main))
  304. cy.findAllByRole('tab').should('have.length', 1)
  305. // Select the same file again
  306. cy.then(() => selectDoc(DOC_IDS.main))
  307. cy.findAllByRole('tab').should('have.length', 1)
  308. })
  309. })
  310. describe('Temporary tabs', function () {
  311. beforeEach(function () {
  312. mountTabs({ userSettings: { previewTabs: true } })
  313. })
  314. it('opens a newly selected file as a temporary tab', function () {
  315. cy.then(() => selectDoc(DOC_IDS.main))
  316. // The first tab is temporary until a keypress
  317. cy.findByRole('tab', { name: /main\.tex/ }).should(
  318. 'have.class',
  319. 'tab-temporary'
  320. )
  321. })
  322. it('makes a temporary tab permanent when a key is pressed', function () {
  323. cy.then(() => selectDoc(DOC_IDS.main))
  324. cy.findByRole('tab', { name: /main\.tex/ }).should(
  325. 'have.class',
  326. 'tab-temporary'
  327. )
  328. cy.get('@editorView').type('a')
  329. cy.findByRole('tab', { name: /main\.tex/ }).should(
  330. 'not.have.class',
  331. 'tab-temporary'
  332. )
  333. })
  334. it('replaces a temporary tab when selecting yet another file', function () {
  335. // Open main (temporary)
  336. cy.then(() => selectDoc(DOC_IDS.main))
  337. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  338. // Make main permanent
  339. cy.get('@editorView').type('a')
  340. // Open intro (temporary)
  341. cy.then(() => selectDoc(DOC_IDS.intro))
  342. cy.findByRole('tab', { name: /intro\.tex/ }).should(
  343. 'have.class',
  344. 'tab-temporary'
  345. )
  346. // Open appendix — should replace temporary intro
  347. cy.then(() => selectDoc(DOC_IDS.appendix))
  348. cy.findByRole('tab', { name: /intro\.tex/ }).should('not.exist')
  349. cy.findByRole('tab', { name: /appendix\.tex/ }).should('exist')
  350. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  351. })
  352. it('does not make a temporary tab permanent on remote changes', function () {
  353. cy.then(() => selectDoc(DOC_IDS.main))
  354. cy.findByRole('tab', { name: /main\.tex/ }).should(
  355. 'have.class',
  356. 'tab-temporary'
  357. )
  358. cy.findByRole('button', { name: 'Add a remote change' }).click()
  359. cy.findByRole('tab', { name: /main\.tex/ }).should(
  360. 'have.class',
  361. 'tab-temporary'
  362. )
  363. })
  364. it('makes a temporary tab permanent on double-click', function () {
  365. cy.then(() => selectDoc(DOC_IDS.main))
  366. cy.findByRole('tab', { name: /main\.tex/ }).should(
  367. 'have.class',
  368. 'tab-temporary'
  369. )
  370. cy.findByRole('tab', { name: /main\.tex/ }).dblclick()
  371. cy.findByRole('tab', { name: /main\.tex/ }).should(
  372. 'not.have.class',
  373. 'tab-temporary'
  374. )
  375. })
  376. it('opens a new tab as permanent when previewTabs is disabled', function () {
  377. mountTabs({ userSettings: { previewTabs: false } })
  378. cy.then(() => selectDoc(DOC_IDS.main))
  379. cy.findByRole('tab', { name: /main\.tex/ }).should(
  380. 'not.have.class',
  381. 'tab-temporary'
  382. )
  383. })
  384. })
  385. describe('Closing tabs', function () {
  386. it('closes a tab via the close button', function () {
  387. // Open main
  388. cy.then(() => selectDoc(DOC_IDS.main))
  389. // Open intro
  390. cy.then(() => selectDoc(DOC_IDS.intro))
  391. cy.findAllByRole('tab').should('have.length', 2)
  392. // Close intro
  393. cy.findByRole('tab', { name: /intro\.tex/ }).within(() => {
  394. cy.findByRole('button', { name: 'Close tab' }).click()
  395. })
  396. cy.findByRole('tab', { name: /intro\.tex/ }).should('not.exist')
  397. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  398. })
  399. it('cannot close the last remaining tab', function () {
  400. cy.then(() => selectDoc(DOC_IDS.main))
  401. cy.findAllByRole('tab').should('have.length', 1)
  402. // Attempt to close the only tab
  403. cy.findByRole('tab', { name: /main\.tex/ }).within(() => {
  404. cy.findByRole('button', { name: 'Close tab' }).click()
  405. })
  406. // Tab must still exist
  407. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  408. cy.findAllByRole('tab').should('have.length', 1)
  409. })
  410. it('switches to an adjacent tab when closing the currently active tab', function () {
  411. // Open three tabs
  412. cy.then(() => selectDoc(DOC_IDS.main))
  413. cy.then(() => selectDoc(DOC_IDS.intro))
  414. cy.then(() => selectDoc(DOC_IDS.appendix))
  415. cy.findAllByRole('tab').should('have.length', 3)
  416. // Close the currently selected tab (appendix — the last one selected)
  417. cy.findByRole('tab', { name: /appendix\.tex/ }).within(() => {
  418. cy.findByRole('button', { name: 'Close tab' }).click()
  419. })
  420. cy.findByRole('tab', { name: /appendix\.tex/ }).should('not.exist')
  421. // The context should have tried to open an adjacent tab
  422. cy.get('@openDocWithId').should('have.been.calledWith', DOC_IDS.intro)
  423. })
  424. it('closes a tab on middle-click', function () {
  425. // Open two tabs
  426. cy.then(() => selectDoc(DOC_IDS.main))
  427. cy.then(() => selectDoc(DOC_IDS.intro))
  428. cy.findAllByRole('tab').should('have.length', 2)
  429. // Middle-click intro tab
  430. cy.findByRole('tab', { name: /intro\.tex/ }).trigger('mouseup', {
  431. button: 1,
  432. })
  433. cy.findByRole('tab', { name: /intro\.tex/ }).should('not.exist')
  434. })
  435. })
  436. describe('Context menu', function () {
  437. it('opens the context menu on right-click of a tab', function () {
  438. cy.then(() => selectDoc(DOC_IDS.main))
  439. cy.then(() => selectDoc(DOC_IDS.intro))
  440. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  441. cy.findByRole('menu').should('exist')
  442. cy.findByRole('menuitem', { name: 'Close tab' }).should('exist')
  443. cy.findByRole('menuitem', { name: 'Close others' }).should('exist')
  444. })
  445. it('closes the clicked tab via "Close tab"', function () {
  446. cy.then(() => selectDoc(DOC_IDS.main))
  447. cy.then(() => selectDoc(DOC_IDS.intro))
  448. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  449. cy.findByRole('menuitem', { name: 'Close tab' }).click()
  450. cy.findByRole('tab', { name: /intro\.tex/ }).should('not.exist')
  451. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  452. cy.findByRole('menu').should('not.exist')
  453. })
  454. it('closes all other tabs via "Close others"', function () {
  455. cy.then(() => selectDoc(DOC_IDS.main))
  456. cy.then(() => selectDoc(DOC_IDS.intro))
  457. cy.then(() => selectDoc(DOC_IDS.appendix))
  458. cy.findAllByRole('tab').should('have.length', 3)
  459. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  460. cy.findByRole('menuitem', { name: 'Close others' }).click()
  461. cy.findAllByRole('tab').should('have.length', 1)
  462. cy.findByRole('tab', { name: /intro\.tex/ }).should('exist')
  463. })
  464. it('navigates to the target tab when closing others from a non-active tab', function () {
  465. cy.then(() => selectDoc(DOC_IDS.main))
  466. cy.then(() => selectDoc(DOC_IDS.intro))
  467. cy.then(() => selectDoc(DOC_IDS.appendix))
  468. // appendix is the currently active tab; close others from intro
  469. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  470. cy.findByRole('menuitem', { name: 'Close others' }).click()
  471. cy.get('@openDocWithId').should('have.been.calledWith', DOC_IDS.intro)
  472. })
  473. it('disables both items when only one tab is open', function () {
  474. cy.then(() => selectDoc(DOC_IDS.main))
  475. cy.findByRole('tab', { name: /main\.tex/ }).rightclick()
  476. cy.findByRole('menuitem', { name: 'Close tab' }).should(
  477. 'have.attr',
  478. 'aria-disabled',
  479. 'true'
  480. )
  481. cy.findByRole('menuitem', { name: 'Close others' }).should(
  482. 'have.attr',
  483. 'aria-disabled',
  484. 'true'
  485. )
  486. })
  487. it('closes the menu on Escape', function () {
  488. cy.then(() => selectDoc(DOC_IDS.main))
  489. cy.then(() => selectDoc(DOC_IDS.intro))
  490. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  491. cy.findByRole('menu').should('exist')
  492. cy.findByRole('menu').trigger('keydown', {
  493. key: 'Escape',
  494. })
  495. cy.findByRole('menu').should('not.exist')
  496. })
  497. it('closes the menu on right-click outside', function () {
  498. cy.then(() => selectDoc(DOC_IDS.main))
  499. cy.then(() => selectDoc(DOC_IDS.intro))
  500. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  501. cy.findByRole('menu').should('exist')
  502. // Right-click outside any tab
  503. cy.get('.editor-tabs-container').rightclick('right')
  504. cy.findByRole('menu').should('not.exist')
  505. })
  506. it('focuses the menu when opening', function () {
  507. cy.then(() => selectDoc(DOC_IDS.main))
  508. cy.then(() => selectDoc(DOC_IDS.intro))
  509. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  510. cy.findByRole('menu').should('be.focused')
  511. })
  512. it('returns focus to the originating tab when closing', function () {
  513. cy.then(() => selectDoc(DOC_IDS.main))
  514. cy.then(() => selectDoc(DOC_IDS.intro))
  515. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  516. cy.findByRole('menu').trigger('keydown', { key: 'Escape' })
  517. cy.findByRole('tab', { name: /intro\.tex/ }).should('be.focused')
  518. })
  519. it('moves the menu to another tab on right-click', function () {
  520. cy.then(() => selectDoc(DOC_IDS.main))
  521. cy.then(() => selectDoc(DOC_IDS.intro))
  522. cy.then(() => selectDoc(DOC_IDS.appendix))
  523. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  524. cy.findByRole('menu').should('exist')
  525. // Right-click appendix the menu should retarget to that tab
  526. cy.findByRole('tab', { name: /appendix\.tex/ }).rightclick({
  527. force: true,
  528. })
  529. cy.findByRole('menuitem', { name: 'Close others' }).click()
  530. cy.findAllByRole('tab').should('have.length', 1)
  531. cy.findByRole('tab', { name: /appendix\.tex/ }).should('exist')
  532. })
  533. it('should not open the context menu if shift is held', function () {
  534. cy.then(() => selectDoc(DOC_IDS.intro))
  535. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick({
  536. shiftKey: true,
  537. })
  538. cy.findByRole('menu').should('not.exist')
  539. })
  540. it('should close already open context menu if shift is held', function () {
  541. cy.then(() => selectDoc(DOC_IDS.intro))
  542. cy.then(() => selectDoc(DOC_IDS.main))
  543. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  544. cy.findByRole('menu').should('exist')
  545. cy.findByRole('tab', { name: /main\.tex/ }).rightclick({
  546. force: true,
  547. shiftKey: true,
  548. })
  549. cy.findByRole('menu').should('not.exist')
  550. })
  551. })
  552. describe('Tab interaction', function () {
  553. it('calls openDocWithId when clicking a non-selected doc tab', function () {
  554. // Open two tabs
  555. cy.then(() => selectDoc(DOC_IDS.main))
  556. cy.then(() => selectDoc(DOC_IDS.intro))
  557. // Click main tab
  558. cy.findByRole('tab', { name: /main\.tex/ }).click()
  559. cy.get('@openDocWithId').should('have.been.calledWith', DOC_IDS.main)
  560. })
  561. it('calls openFileWithId when clicking a non-selected fileRef tab', function () {
  562. const rootFolder = makeRootFolder(
  563. [{ _id: DOC_IDS.main, name: 'main.tex' }],
  564. [],
  565. [{ _id: DOC_IDS.bibFile, name: 'refs.bib' }]
  566. )
  567. mountTabs({ rootFolder })
  568. // Open main doc
  569. cy.then(() => selectDoc(DOC_IDS.main))
  570. // Open fileRef
  571. cy.then(() =>
  572. selectEntity(makeFileRefEntity(DOC_IDS.bibFile, 'refs.bib'))
  573. )
  574. // Switch back to main so refs.bib is no longer selected
  575. cy.findByRole('tab', { name: /main\.tex/ }).click()
  576. // Click the fileRef tab
  577. cy.findByRole('tab', { name: /refs\.bib/ }).click()
  578. cy.get('@openFileWithId').should('have.been.calledWith', DOC_IDS.bibFile)
  579. })
  580. })
  581. describe('Drag and drop', function () {
  582. it('reorders tabs by dragging to the right of another tab', function () {
  583. // Open three tabs
  584. cy.then(() => selectDoc(DOC_IDS.main))
  585. cy.then(() => selectDoc(DOC_IDS.intro))
  586. cy.then(() => selectDoc(DOC_IDS.appendix))
  587. cy.findAllByRole('tab').should('have.length', 3)
  588. // Verify initial order
  589. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  590. cy.findAllByRole('tab').eq(1).should('contain.text', 'intro.tex')
  591. cy.findAllByRole('tab').eq(2).should('contain.text', 'appendix.tex')
  592. // Drag main to the right of appendix
  593. const dataTransfer = new DataTransfer()
  594. dataTransfer.setData(TAB_TRANSFER_TYPE, DOC_IDS.main)
  595. cy.findByRole('tab', { name: /main\.tex/ }).trigger('dragstart', {
  596. dataTransfer,
  597. })
  598. cy.findByRole('tab', { name: /appendix\.tex/ }).then($el => {
  599. const rect = $el[0].getBoundingClientRect()
  600. cy.wrap($el).trigger('dragover', {
  601. dataTransfer,
  602. clientX: rect.left + rect.width * 0.75,
  603. })
  604. cy.wrap($el).trigger('drop', {
  605. dataTransfer,
  606. clientX: rect.left + rect.width * 0.75,
  607. })
  608. })
  609. // Expected order after move: intro, appendix, main
  610. cy.findAllByRole('tab').eq(0).should('contain.text', 'intro.tex')
  611. cy.findAllByRole('tab').eq(1).should('contain.text', 'appendix.tex')
  612. cy.findAllByRole('tab').eq(2).should('contain.text', 'main.tex')
  613. })
  614. it('reorders tabs by dragging to the left of another tab', function () {
  615. // Open three tabs
  616. cy.then(() => selectDoc(DOC_IDS.main))
  617. cy.then(() => selectDoc(DOC_IDS.intro))
  618. cy.then(() => selectDoc(DOC_IDS.appendix))
  619. // Verify initial order
  620. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  621. cy.findAllByRole('tab').eq(1).should('contain.text', 'intro.tex')
  622. cy.findAllByRole('tab').eq(2).should('contain.text', 'appendix.tex')
  623. // Drag appendix to the left of main
  624. const dataTransfer = new DataTransfer()
  625. dataTransfer.setData(TAB_TRANSFER_TYPE, DOC_IDS.appendix)
  626. cy.findByRole('tab', { name: /appendix\.tex/ }).trigger('dragstart', {
  627. dataTransfer,
  628. })
  629. cy.findByRole('tab', { name: /main\.tex/ }).then($el => {
  630. const rect = $el[0].getBoundingClientRect()
  631. cy.wrap($el).trigger('dragover', {
  632. dataTransfer,
  633. clientX: rect.left + rect.width * 0.25,
  634. })
  635. cy.wrap($el).trigger('drop', {
  636. dataTransfer,
  637. clientX: rect.left + rect.width * 0.25,
  638. })
  639. })
  640. // Expected order: appendix, main, intro
  641. cy.findAllByRole('tab').eq(0).should('contain.text', 'appendix.tex')
  642. cy.findAllByRole('tab').eq(1).should('contain.text', 'main.tex')
  643. cy.findAllByRole('tab').eq(2).should('contain.text', 'intro.tex')
  644. })
  645. it('shows a drop indicator when dragging over a tab', function () {
  646. // Open two tabs
  647. cy.then(() => selectDoc(DOC_IDS.main))
  648. cy.then(() => selectDoc(DOC_IDS.intro))
  649. const dataTransfer = new DataTransfer()
  650. dataTransfer.setData(TAB_TRANSFER_TYPE, DOC_IDS.intro)
  651. cy.findByRole('tab', { name: /intro\.tex/ }).trigger('dragstart', {
  652. dataTransfer,
  653. })
  654. // Drag over left side of main tab
  655. cy.findByRole('tab', { name: /main\.tex/ }).then($el => {
  656. const rect = $el[0].getBoundingClientRect()
  657. cy.wrap($el).trigger('dragover', {
  658. dataTransfer,
  659. clientX: rect.left + rect.width * 0.25,
  660. })
  661. })
  662. cy.findByRole('tab', { name: /main\.tex/ }).should(
  663. 'have.class',
  664. 'tab-drop-left'
  665. )
  666. // Drag leave should clear the indicator
  667. cy.findByRole('tab', { name: /main\.tex/ }).trigger('dragleave')
  668. cy.findByRole('tab', { name: /main\.tex/ }).should(
  669. 'not.have.class',
  670. 'tab-drop-left'
  671. )
  672. })
  673. it('drops onto the tablist to move a tab to the end', function () {
  674. // Open three tabs
  675. cy.then(() => selectDoc(DOC_IDS.main))
  676. cy.then(() => selectDoc(DOC_IDS.intro))
  677. cy.then(() => selectDoc(DOC_IDS.appendix))
  678. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  679. // Drag main to the tablist gutter (right of last tab)
  680. const dataTransfer = new DataTransfer()
  681. dataTransfer.setData(TAB_TRANSFER_TYPE, DOC_IDS.main)
  682. cy.findByRole('tab', { name: /main\.tex/ }).trigger('dragstart', {
  683. dataTransfer,
  684. })
  685. cy.findByRole('tablist').trigger('dragover', { dataTransfer })
  686. cy.findByRole('tablist').trigger('drop', { dataTransfer })
  687. // main should now be last
  688. cy.findAllByRole('tab').last().should('contain.text', 'main.tex')
  689. })
  690. it('does nothing when dropping a tab on itself', function () {
  691. // Open two tabs
  692. cy.then(() => selectDoc(DOC_IDS.main))
  693. cy.then(() => selectDoc(DOC_IDS.intro))
  694. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  695. cy.findAllByRole('tab').eq(1).should('contain.text', 'intro.tex')
  696. // Drag main onto itself
  697. const dataTransfer = new DataTransfer()
  698. dataTransfer.setData(TAB_TRANSFER_TYPE, DOC_IDS.main)
  699. cy.findByRole('tab', { name: /main\.tex/ }).trigger('dragstart', {
  700. dataTransfer,
  701. })
  702. cy.findByRole('tab', { name: /main\.tex/ }).then($el => {
  703. const rect = $el[0].getBoundingClientRect()
  704. cy.wrap($el).trigger('dragover', {
  705. dataTransfer,
  706. clientX: rect.left + rect.width * 0.75,
  707. })
  708. cy.wrap($el).trigger('drop', {
  709. dataTransfer,
  710. clientX: rect.left + rect.width * 0.75,
  711. })
  712. })
  713. // Order unchanged
  714. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  715. cy.findAllByRole('tab').eq(1).should('contain.text', 'intro.tex')
  716. })
  717. })
  718. describe('Path disambiguation for duplicate names', function () {
  719. const duplicateNameRootFolder = makeRootFolder(
  720. [{ _id: DOC_IDS.main, name: 'main.tex' }],
  721. [
  722. {
  723. _id: FOLDER_IDS.chapterA,
  724. name: 'chapter-a',
  725. docs: [{ _id: DOC_IDS.introA, name: 'intro.tex' }],
  726. folders: [],
  727. fileRefs: [],
  728. },
  729. {
  730. _id: FOLDER_IDS.chapterB,
  731. name: 'chapter-b',
  732. docs: [{ _id: DOC_IDS.introB, name: 'intro.tex' }],
  733. folders: [],
  734. fileRefs: [],
  735. },
  736. ]
  737. )
  738. it('shows disambiguated paths when two files share the same name', function () {
  739. mountTabs({ rootFolder: duplicateNameRootFolder })
  740. // Open main.tex (unique name, no disambiguation needed)
  741. cy.then(() => selectDoc(DOC_IDS.main))
  742. // Open intro.tex from chapter-a
  743. cy.then(() =>
  744. selectDoc(DOC_IDS.introA, ['root-folder-id', FOLDER_IDS.chapterA])
  745. )
  746. // Open intro.tex from chapter-b
  747. cy.then(() =>
  748. selectDoc(DOC_IDS.introB, ['root-folder-id', FOLDER_IDS.chapterB])
  749. )
  750. cy.findAllByRole('tab').should('have.length', 3)
  751. // main.tex is unique — should display without a path prefix
  752. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  753. // The two intro.tex tabs should be disambiguated with their folder names
  754. cy.findAllByRole('tab')
  755. .eq(1)
  756. .should('contain.text', 'chapter-a/intro.tex')
  757. cy.findAllByRole('tab')
  758. .eq(2)
  759. .should('contain.text', 'chapter-b/intro.tex')
  760. })
  761. it('uses only the file name when there is no collision', function () {
  762. mountTabs({ rootFolder: duplicateNameRootFolder })
  763. // Open just one of the duplicate-named files
  764. cy.then(() =>
  765. selectDoc(DOC_IDS.introA, ['root-folder-id', FOLDER_IDS.chapterA])
  766. )
  767. // With only one intro.tex open, no disambiguation is needed
  768. cy.findByRole('tab', { name: /intro\.tex/ }).should('exist')
  769. cy.findByRole('tab', { name: /intro\.tex/ }).should(
  770. 'not.contain.text',
  771. 'chapter-a/'
  772. )
  773. })
  774. })
  775. describe('Multiple file types', function () {
  776. it('displays tabs for both docs and file refs', function () {
  777. const rootFolder = makeRootFolder(
  778. [{ _id: DOC_IDS.main, name: 'main.tex' }],
  779. [],
  780. [{ _id: DOC_IDS.bibFile, name: 'refs.bib' }]
  781. )
  782. mountTabs({ rootFolder })
  783. // Open main doc
  784. cy.then(() => selectDoc(DOC_IDS.main))
  785. // Open a fileRef
  786. cy.then(() =>
  787. selectEntity(makeFileRefEntity(DOC_IDS.bibFile, 'refs.bib'))
  788. )
  789. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  790. cy.findByRole('tab', { name: /refs\.bib/ }).should('exist')
  791. })
  792. })
  793. describe('Tab scrolling', function () {
  794. it('scrolls the selected tab into view', function () {
  795. const manyDocs = [
  796. { _id: DOC_IDS.main, name: 'main.tex' },
  797. ...Array.from({ length: 10 }, (_, i) => ({
  798. _id: `ch${i + 1}`,
  799. name: `chapter-${i + 1}.tex`,
  800. })),
  801. ]
  802. const rootFolder = makeRootFolder(manyDocs)
  803. mountTabs({ rootFolder })
  804. // Open main
  805. cy.then(() => selectDoc(DOC_IDS.main))
  806. // Open all chapter tabs first so they push main.tex out of view
  807. for (let i = 1; i <= 10; i++) {
  808. const id = `ch${i}`
  809. cy.then(() => selectEntity(makeDocEntity(id, `chapter-${i}.tex`)))
  810. cy.get('@editorView').type('a')
  811. cy.findByRole('tab', { name: new RegExp(`chapter-${i}.tex`) }).should(
  812. 'be.visible'
  813. )
  814. }
  815. // Now select main again — it should be scrolled back into view
  816. cy.then(() => selectDoc(DOC_IDS.main))
  817. cy.findByRole('tab', { name: /main\.tex/ }).should('be.visible')
  818. })
  819. it('scrolls horizontally on vertical mouse wheel', function () {
  820. const manyDocs = [
  821. { _id: DOC_IDS.main, name: 'main.tex' },
  822. ...Array.from({ length: 10 }, (_, i) => ({
  823. _id: `ch${i + 1}`,
  824. name: `chapter-${i + 1}.tex`,
  825. })),
  826. ]
  827. const rootFolder = makeRootFolder(manyDocs)
  828. mountTabs({ rootFolder })
  829. // Open enough tabs to cause overflow
  830. cy.then(() => selectDoc(DOC_IDS.main))
  831. for (let i = 1; i <= 10; i++) {
  832. const id = `ch${i}`
  833. cy.then(() => selectEntity(makeDocEntity(id, `chapter-${i}.tex`)))
  834. }
  835. // Scroll back to the start
  836. cy.findByRole('tablist').then($el => {
  837. $el[0].scrollLeft = 0
  838. })
  839. // Trigger a vertical wheel event on the tablist
  840. cy.findByRole('tablist').trigger('wheel', { deltaY: 100, deltaX: 0 })
  841. // scrollLeft should have increased
  842. cy.findByRole('tablist').should($el => {
  843. expect($el[0].scrollLeft).to.be.greaterThan(0)
  844. })
  845. })
  846. })
  847. describe('Pruning deleted files', function () {
  848. it('prunes persisted tabs whose files are no longer in the tree', function () {
  849. cy.then(() => selectDoc(DOC_IDS.main))
  850. cy.then(() => selectDoc(DOC_IDS.intro))
  851. cy.then(() => selectDoc(DOC_IDS.appendix))
  852. cy.findAllByRole('tab').should('have.length', 3)
  853. // Re-mount with a tree containing only appendix.tex, then verify
  854. // the last remaining tab cannot be closed
  855. const trimmedRootFolder = makeRootFolder([
  856. { _id: DOC_IDS.appendix, name: 'appendix.tex' },
  857. ])
  858. mountTabs({ rootFolder: trimmedRootFolder })
  859. cy.findAllByRole('tab').should('have.length', 1)
  860. cy.findByRole('tab', { name: /appendix\.tex/ }).should('exist')
  861. cy.findByRole('tab', { name: /appendix\.tex/ }).within(() => {
  862. cy.findByRole('button', { name: 'Close tab' }).click()
  863. })
  864. cy.findAllByRole('tab').should('have.length', 1)
  865. cy.findByRole('tab', { name: /appendix\.tex/ }).should('exist')
  866. })
  867. })
  868. describe('SplitTestBadge', function () {
  869. it('renders the labs badge icon in the tabs container', function () {
  870. cy.window().then(win => {
  871. win.metaAttributesCache.set('ol-splitTestInfo', {
  872. 'editor-tabs': {
  873. phase: 'beta',
  874. badgeInfo: {
  875. url: '/beta/editor-tabs',
  876. tooltipText: 'Editor tabs are in beta',
  877. },
  878. },
  879. })
  880. })
  881. mountTabs()
  882. cy.then(() => selectDoc(DOC_IDS.main))
  883. cy.get('.editor-tabs-labs-icon').should('exist')
  884. })
  885. })
  886. })