tabs.spec.tsx 38 KB

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