tabs.spec.tsx 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182
  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' }).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' }).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' }).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' }).should('exist')
  443. cy.findByRole('menuitem', { name: 'Close other tabs' }).should('exist')
  444. cy.findByRole('menuitem', { name: 'Close tabs to the right' }).should(
  445. 'exist'
  446. )
  447. cy.findByRole('menuitem', { name: 'Tab settings…' }).should('exist')
  448. })
  449. it('closes the clicked tab via "Close"', function () {
  450. cy.then(() => selectDoc(DOC_IDS.main))
  451. cy.then(() => selectDoc(DOC_IDS.intro))
  452. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  453. cy.findByRole('menuitem', { name: 'Close' }).click()
  454. cy.findByRole('tab', { name: /intro\.tex/ }).should('not.exist')
  455. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  456. cy.findByRole('menu').should('not.exist')
  457. })
  458. it('closes all other tabs via "Close other tabs"', function () {
  459. cy.then(() => selectDoc(DOC_IDS.main))
  460. cy.then(() => selectDoc(DOC_IDS.intro))
  461. cy.then(() => selectDoc(DOC_IDS.appendix))
  462. cy.findAllByRole('tab').should('have.length', 3)
  463. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  464. cy.findByRole('menuitem', { name: 'Close other tabs' }).click()
  465. cy.findAllByRole('tab').should('have.length', 1)
  466. cy.findByRole('tab', { name: /intro\.tex/ }).should('exist')
  467. })
  468. it('navigates to the target tab when closing others from a non-active tab', function () {
  469. cy.then(() => selectDoc(DOC_IDS.main))
  470. cy.then(() => selectDoc(DOC_IDS.intro))
  471. cy.then(() => selectDoc(DOC_IDS.appendix))
  472. // appendix is the currently active tab; close others from intro
  473. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  474. cy.findByRole('menuitem', { name: 'Close other tabs' }).click()
  475. cy.get('@openDocWithId').should('have.been.calledWith', DOC_IDS.intro)
  476. })
  477. it('closes tabs to the right of the target via "Close tabs to the right"', function () {
  478. cy.then(() => selectDoc(DOC_IDS.main))
  479. cy.then(() => selectDoc(DOC_IDS.intro))
  480. cy.then(() => selectDoc(DOC_IDS.appendix))
  481. cy.findAllByRole('tab').should('have.length', 3)
  482. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  483. cy.findByRole('menuitem', { name: 'Close tabs to the right' }).click()
  484. cy.findAllByRole('tab').should('have.length', 2)
  485. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  486. cy.findByRole('tab', { name: /intro\.tex/ }).should('exist')
  487. cy.findByRole('tab', { name: /appendix\.tex/ }).should('not.exist')
  488. })
  489. it('navigates to the target tab when the active tab is closed to the right', function () {
  490. cy.then(() => selectDoc(DOC_IDS.main))
  491. cy.then(() => selectDoc(DOC_IDS.intro))
  492. cy.then(() => selectDoc(DOC_IDS.appendix))
  493. // appendix is the active tab; closing to the right of intro removes it
  494. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  495. cy.findByRole('menuitem', { name: 'Close tabs to the right' }).click()
  496. cy.get('@openDocWithId').should('have.been.calledWith', DOC_IDS.intro)
  497. })
  498. it('keeps the active tab selected when it is to the left of the target', function () {
  499. cy.then(() => selectDoc(DOC_IDS.main))
  500. cy.then(() => selectDoc(DOC_IDS.intro))
  501. cy.then(() => selectDoc(DOC_IDS.appendix))
  502. // Re-select main so it becomes the active tab again (still at index 0)
  503. cy.then(() => selectDoc(DOC_IDS.main))
  504. // Closing to the right of intro removes appendix but not the active tab
  505. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  506. cy.findByRole('menuitem', { name: 'Close tabs to the right' }).click()
  507. cy.findAllByRole('tab').should('have.length', 2)
  508. cy.findByRole('tab', { name: /appendix\.tex/ }).should('not.exist')
  509. cy.findByRole('tab', { name: /main\.tex/ }).should(
  510. 'have.attr',
  511. 'aria-selected',
  512. 'true'
  513. )
  514. })
  515. it('disables the close actions when only one tab is open', function () {
  516. cy.then(() => selectDoc(DOC_IDS.main))
  517. cy.findByRole('tab', { name: /main\.tex/ }).rightclick()
  518. cy.findByRole('menuitem', { name: 'Close' }).should(
  519. 'have.attr',
  520. 'aria-disabled',
  521. 'true'
  522. )
  523. cy.findByRole('menuitem', { name: 'Close other tabs' }).should(
  524. 'have.attr',
  525. 'aria-disabled',
  526. 'true'
  527. )
  528. cy.findByRole('menuitem', { name: 'Close tabs to the right' }).should(
  529. 'have.attr',
  530. 'aria-disabled',
  531. 'true'
  532. )
  533. })
  534. it('disables "Close tabs to the right" when the target is the last tab', function () {
  535. cy.then(() => selectDoc(DOC_IDS.main))
  536. cy.then(() => selectDoc(DOC_IDS.intro))
  537. cy.then(() => selectDoc(DOC_IDS.appendix))
  538. // appendix is the rightmost tab, so there is nothing to close to its right
  539. cy.findByRole('tab', { name: /appendix\.tex/ }).rightclick()
  540. cy.findByRole('menuitem', { name: 'Close tabs to the right' }).should(
  541. 'have.attr',
  542. 'aria-disabled',
  543. 'true'
  544. )
  545. // The other actions remain enabled with more than one tab open
  546. cy.findByRole('menuitem', { name: 'Close' }).should(
  547. 'not.have.attr',
  548. 'aria-disabled',
  549. 'true'
  550. )
  551. cy.findByRole('menuitem', { name: 'Close other tabs' }).should(
  552. 'not.have.attr',
  553. 'aria-disabled',
  554. 'true'
  555. )
  556. })
  557. it('opens tab settings via "Tab settings…"', function () {
  558. cy.then(() => selectDoc(DOC_IDS.main))
  559. cy.then(() => selectDoc(DOC_IDS.intro))
  560. cy.window().then(win => {
  561. cy.spy(win, 'dispatchEvent').as('dispatchEvent')
  562. })
  563. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  564. cy.findByRole('menuitem', { name: 'Tab settings…' }).click()
  565. // opens the left settings menu...
  566. cy.get('@dispatchEvent').should(
  567. 'have.been.calledWithMatch',
  568. Cypress.sinon.match({ type: 'ui.toggle-left-menu', detail: true })
  569. )
  570. // ...and focuses the previewTabs setting
  571. cy.get('@dispatchEvent').should(
  572. 'have.been.calledWithMatch',
  573. Cypress.sinon.match({ type: 'ui.focus-setting', detail: 'previewTabs' })
  574. )
  575. // and the context menu closes
  576. cy.findByRole('menu').should('not.exist')
  577. })
  578. it('closes the menu on Escape', function () {
  579. cy.then(() => selectDoc(DOC_IDS.main))
  580. cy.then(() => selectDoc(DOC_IDS.intro))
  581. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  582. cy.findByRole('menu').should('exist')
  583. cy.findByRole('menu').trigger('keydown', {
  584. key: 'Escape',
  585. })
  586. cy.findByRole('menu').should('not.exist')
  587. })
  588. it('closes the menu on right-click outside', function () {
  589. cy.then(() => selectDoc(DOC_IDS.main))
  590. cy.then(() => selectDoc(DOC_IDS.intro))
  591. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  592. cy.findByRole('menu').should('exist')
  593. // Right-click outside any tab
  594. cy.get('.editor-tabs-container').rightclick('right')
  595. cy.findByRole('menu').should('not.exist')
  596. })
  597. it('focuses the menu when opening', function () {
  598. cy.then(() => selectDoc(DOC_IDS.main))
  599. cy.then(() => selectDoc(DOC_IDS.intro))
  600. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  601. cy.findByRole('menu').should('be.focused')
  602. })
  603. it('returns focus to the originating tab when closing', function () {
  604. cy.then(() => selectDoc(DOC_IDS.main))
  605. cy.then(() => selectDoc(DOC_IDS.intro))
  606. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  607. cy.findByRole('menu').trigger('keydown', { key: 'Escape' })
  608. cy.findByRole('tab', { name: /intro\.tex/ }).should('be.focused')
  609. })
  610. it('moves the menu to another tab on right-click', function () {
  611. cy.then(() => selectDoc(DOC_IDS.main))
  612. cy.then(() => selectDoc(DOC_IDS.intro))
  613. cy.then(() => selectDoc(DOC_IDS.appendix))
  614. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  615. cy.findByRole('menu').should('exist')
  616. // Right-click appendix the menu should retarget to that tab
  617. cy.findByRole('tab', { name: /appendix\.tex/ }).rightclick({
  618. force: true,
  619. })
  620. cy.findByRole('menuitem', { name: 'Close other tabs' }).click()
  621. cy.findAllByRole('tab').should('have.length', 1)
  622. cy.findByRole('tab', { name: /appendix\.tex/ }).should('exist')
  623. })
  624. it('should not open the context menu if shift is held', function () {
  625. cy.then(() => selectDoc(DOC_IDS.intro))
  626. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick({
  627. shiftKey: true,
  628. })
  629. cy.findByRole('menu').should('not.exist')
  630. })
  631. it('should close already open context menu if shift is held', function () {
  632. cy.then(() => selectDoc(DOC_IDS.intro))
  633. cy.then(() => selectDoc(DOC_IDS.main))
  634. cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
  635. cy.findByRole('menu').should('exist')
  636. cy.findByRole('tab', { name: /main\.tex/ }).rightclick({
  637. force: true,
  638. shiftKey: true,
  639. })
  640. cy.findByRole('menu').should('not.exist')
  641. })
  642. })
  643. describe('Tab interaction', function () {
  644. it('calls openDocWithId when clicking a non-selected doc tab', function () {
  645. // Open two tabs
  646. cy.then(() => selectDoc(DOC_IDS.main))
  647. cy.then(() => selectDoc(DOC_IDS.intro))
  648. // Click main tab
  649. cy.findByRole('tab', { name: /main\.tex/ }).click()
  650. cy.get('@openDocWithId').should('have.been.calledWith', DOC_IDS.main)
  651. })
  652. it('calls openFileWithId when clicking a non-selected fileRef tab', function () {
  653. const rootFolder = makeRootFolder(
  654. [{ _id: DOC_IDS.main, name: 'main.tex' }],
  655. [],
  656. [{ _id: DOC_IDS.bibFile, name: 'refs.bib' }]
  657. )
  658. mountTabs({ rootFolder })
  659. // Open main doc
  660. cy.then(() => selectDoc(DOC_IDS.main))
  661. // Open fileRef
  662. cy.then(() =>
  663. selectEntity(makeFileRefEntity(DOC_IDS.bibFile, 'refs.bib'))
  664. )
  665. // Switch back to main so refs.bib is no longer selected
  666. cy.findByRole('tab', { name: /main\.tex/ }).click()
  667. // Click the fileRef tab
  668. cy.findByRole('tab', { name: /refs\.bib/ }).click()
  669. cy.get('@openFileWithId').should('have.been.calledWith', DOC_IDS.bibFile)
  670. })
  671. })
  672. describe('Drag and drop', function () {
  673. it('reorders tabs by dragging to the right of another tab', 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').should('have.length', 3)
  679. // Verify initial order
  680. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  681. cy.findAllByRole('tab').eq(1).should('contain.text', 'intro.tex')
  682. cy.findAllByRole('tab').eq(2).should('contain.text', 'appendix.tex')
  683. // Drag main to the right of appendix
  684. const dataTransfer = new DataTransfer()
  685. dataTransfer.setData(TAB_TRANSFER_TYPE, DOC_IDS.main)
  686. cy.findByRole('tab', { name: /main\.tex/ }).trigger('dragstart', {
  687. dataTransfer,
  688. })
  689. cy.findByRole('tab', { name: /appendix\.tex/ }).then($el => {
  690. const rect = $el[0].getBoundingClientRect()
  691. cy.wrap($el).trigger('dragover', {
  692. dataTransfer,
  693. clientX: rect.left + rect.width * 0.75,
  694. })
  695. cy.wrap($el).trigger('drop', {
  696. dataTransfer,
  697. clientX: rect.left + rect.width * 0.75,
  698. })
  699. })
  700. // Expected order after move: intro, appendix, main
  701. cy.findAllByRole('tab').eq(0).should('contain.text', 'intro.tex')
  702. cy.findAllByRole('tab').eq(1).should('contain.text', 'appendix.tex')
  703. cy.findAllByRole('tab').eq(2).should('contain.text', 'main.tex')
  704. })
  705. it('reorders tabs by dragging to the left of another tab', function () {
  706. // Open three tabs
  707. cy.then(() => selectDoc(DOC_IDS.main))
  708. cy.then(() => selectDoc(DOC_IDS.intro))
  709. cy.then(() => selectDoc(DOC_IDS.appendix))
  710. // Verify initial order
  711. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  712. cy.findAllByRole('tab').eq(1).should('contain.text', 'intro.tex')
  713. cy.findAllByRole('tab').eq(2).should('contain.text', 'appendix.tex')
  714. // Drag appendix to the left of main
  715. const dataTransfer = new DataTransfer()
  716. dataTransfer.setData(TAB_TRANSFER_TYPE, DOC_IDS.appendix)
  717. cy.findByRole('tab', { name: /appendix\.tex/ }).trigger('dragstart', {
  718. dataTransfer,
  719. })
  720. cy.findByRole('tab', { name: /main\.tex/ }).then($el => {
  721. const rect = $el[0].getBoundingClientRect()
  722. cy.wrap($el).trigger('dragover', {
  723. dataTransfer,
  724. clientX: rect.left + rect.width * 0.25,
  725. })
  726. cy.wrap($el).trigger('drop', {
  727. dataTransfer,
  728. clientX: rect.left + rect.width * 0.25,
  729. })
  730. })
  731. // Expected order: appendix, main, intro
  732. cy.findAllByRole('tab').eq(0).should('contain.text', 'appendix.tex')
  733. cy.findAllByRole('tab').eq(1).should('contain.text', 'main.tex')
  734. cy.findAllByRole('tab').eq(2).should('contain.text', 'intro.tex')
  735. })
  736. it('shows a drop indicator when dragging over a tab', function () {
  737. // Open two tabs
  738. cy.then(() => selectDoc(DOC_IDS.main))
  739. cy.then(() => selectDoc(DOC_IDS.intro))
  740. const dataTransfer = new DataTransfer()
  741. dataTransfer.setData(TAB_TRANSFER_TYPE, DOC_IDS.intro)
  742. cy.findByRole('tab', { name: /intro\.tex/ }).trigger('dragstart', {
  743. dataTransfer,
  744. })
  745. // Drag over left side of main tab
  746. cy.findByRole('tab', { name: /main\.tex/ }).then($el => {
  747. const rect = $el[0].getBoundingClientRect()
  748. cy.wrap($el).trigger('dragover', {
  749. dataTransfer,
  750. clientX: rect.left + rect.width * 0.25,
  751. })
  752. })
  753. cy.findByRole('tab', { name: /main\.tex/ }).should(
  754. 'have.class',
  755. 'tab-drop-left'
  756. )
  757. // Drag leave should clear the indicator
  758. cy.findByRole('tab', { name: /main\.tex/ }).trigger('dragleave')
  759. cy.findByRole('tab', { name: /main\.tex/ }).should(
  760. 'not.have.class',
  761. 'tab-drop-left'
  762. )
  763. })
  764. it('drops onto the tablist to move a tab to the end', function () {
  765. // Open three tabs
  766. cy.then(() => selectDoc(DOC_IDS.main))
  767. cy.then(() => selectDoc(DOC_IDS.intro))
  768. cy.then(() => selectDoc(DOC_IDS.appendix))
  769. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  770. // Drag main to the tablist gutter (right of last tab)
  771. const dataTransfer = new DataTransfer()
  772. dataTransfer.setData(TAB_TRANSFER_TYPE, DOC_IDS.main)
  773. cy.findByRole('tab', { name: /main\.tex/ }).trigger('dragstart', {
  774. dataTransfer,
  775. })
  776. cy.findByRole('tablist').trigger('dragover', { dataTransfer })
  777. cy.findByRole('tablist').trigger('drop', { dataTransfer })
  778. // main should now be last
  779. cy.findAllByRole('tab').last().should('contain.text', 'main.tex')
  780. })
  781. it('does nothing when dropping a tab on itself', function () {
  782. // Open two tabs
  783. cy.then(() => selectDoc(DOC_IDS.main))
  784. cy.then(() => selectDoc(DOC_IDS.intro))
  785. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  786. cy.findAllByRole('tab').eq(1).should('contain.text', 'intro.tex')
  787. // Drag main onto itself
  788. const dataTransfer = new DataTransfer()
  789. dataTransfer.setData(TAB_TRANSFER_TYPE, DOC_IDS.main)
  790. cy.findByRole('tab', { name: /main\.tex/ }).trigger('dragstart', {
  791. dataTransfer,
  792. })
  793. cy.findByRole('tab', { name: /main\.tex/ }).then($el => {
  794. const rect = $el[0].getBoundingClientRect()
  795. cy.wrap($el).trigger('dragover', {
  796. dataTransfer,
  797. clientX: rect.left + rect.width * 0.75,
  798. })
  799. cy.wrap($el).trigger('drop', {
  800. dataTransfer,
  801. clientX: rect.left + rect.width * 0.75,
  802. })
  803. })
  804. // Order unchanged
  805. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  806. cy.findAllByRole('tab').eq(1).should('contain.text', 'intro.tex')
  807. })
  808. })
  809. describe('Path disambiguation for duplicate names', function () {
  810. const duplicateNameRootFolder = makeRootFolder(
  811. [{ _id: DOC_IDS.main, name: 'main.tex' }],
  812. [
  813. {
  814. _id: FOLDER_IDS.chapterA,
  815. name: 'chapter-a',
  816. docs: [{ _id: DOC_IDS.introA, name: 'intro.tex' }],
  817. folders: [],
  818. fileRefs: [],
  819. },
  820. {
  821. _id: FOLDER_IDS.chapterB,
  822. name: 'chapter-b',
  823. docs: [{ _id: DOC_IDS.introB, name: 'intro.tex' }],
  824. folders: [],
  825. fileRefs: [],
  826. },
  827. ]
  828. )
  829. it('shows disambiguated paths when two files share the same name', function () {
  830. mountTabs({ rootFolder: duplicateNameRootFolder })
  831. // Open main.tex (unique name, no disambiguation needed)
  832. cy.then(() => selectDoc(DOC_IDS.main))
  833. // Open intro.tex from chapter-a
  834. cy.then(() =>
  835. selectDoc(DOC_IDS.introA, ['root-folder-id', FOLDER_IDS.chapterA])
  836. )
  837. // Open intro.tex from chapter-b
  838. cy.then(() =>
  839. selectDoc(DOC_IDS.introB, ['root-folder-id', FOLDER_IDS.chapterB])
  840. )
  841. cy.findAllByRole('tab').should('have.length', 3)
  842. // main.tex is unique — should display without a path prefix
  843. cy.findAllByRole('tab').eq(0).should('contain.text', 'main.tex')
  844. // The two intro.tex tabs should be disambiguated with their folder names
  845. cy.findAllByRole('tab')
  846. .eq(1)
  847. .should('contain.text', 'chapter-a/intro.tex')
  848. cy.findAllByRole('tab')
  849. .eq(2)
  850. .should('contain.text', 'chapter-b/intro.tex')
  851. })
  852. it('uses only the file name when there is no collision', function () {
  853. mountTabs({ rootFolder: duplicateNameRootFolder })
  854. // Open just one of the duplicate-named files
  855. cy.then(() =>
  856. selectDoc(DOC_IDS.introA, ['root-folder-id', FOLDER_IDS.chapterA])
  857. )
  858. // With only one intro.tex open, no disambiguation is needed
  859. cy.findByRole('tab', { name: /intro\.tex/ }).should('exist')
  860. cy.findByRole('tab', { name: /intro\.tex/ }).should(
  861. 'not.contain.text',
  862. 'chapter-a/'
  863. )
  864. })
  865. })
  866. describe('Multiple file types', function () {
  867. it('displays tabs for both docs and file refs', function () {
  868. const rootFolder = makeRootFolder(
  869. [{ _id: DOC_IDS.main, name: 'main.tex' }],
  870. [],
  871. [{ _id: DOC_IDS.bibFile, name: 'refs.bib' }]
  872. )
  873. mountTabs({ rootFolder })
  874. // Open main doc
  875. cy.then(() => selectDoc(DOC_IDS.main))
  876. // Open a fileRef
  877. cy.then(() =>
  878. selectEntity(makeFileRefEntity(DOC_IDS.bibFile, 'refs.bib'))
  879. )
  880. cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
  881. cy.findByRole('tab', { name: /refs\.bib/ }).should('exist')
  882. })
  883. })
  884. describe('Tab scrolling', function () {
  885. it('scrolls the selected tab into view', function () {
  886. const manyDocs = [
  887. { _id: DOC_IDS.main, name: 'main.tex' },
  888. ...Array.from({ length: 10 }, (_, i) => ({
  889. _id: `ch${i + 1}`,
  890. name: `chapter-${i + 1}.tex`,
  891. })),
  892. ]
  893. const rootFolder = makeRootFolder(manyDocs)
  894. mountTabs({ rootFolder })
  895. // Open main
  896. cy.then(() => selectDoc(DOC_IDS.main))
  897. // Open all chapter tabs first so they push main.tex out of view
  898. for (let i = 1; i <= 10; i++) {
  899. const id = `ch${i}`
  900. cy.then(() => selectEntity(makeDocEntity(id, `chapter-${i}.tex`)))
  901. cy.get('@editorView').type('a')
  902. cy.findByRole('tab', { name: new RegExp(`chapter-${i}.tex`) }).should(
  903. 'be.visible'
  904. )
  905. }
  906. // Now select main again — it should be scrolled back into view
  907. cy.then(() => selectDoc(DOC_IDS.main))
  908. cy.findByRole('tab', { name: /main\.tex/ }).should('be.visible')
  909. })
  910. it('scrolls horizontally on vertical mouse wheel', function () {
  911. const manyDocs = [
  912. { _id: DOC_IDS.main, name: 'main.tex' },
  913. ...Array.from({ length: 10 }, (_, i) => ({
  914. _id: `ch${i + 1}`,
  915. name: `chapter-${i + 1}.tex`,
  916. })),
  917. ]
  918. const rootFolder = makeRootFolder(manyDocs)
  919. mountTabs({ rootFolder })
  920. // Open enough tabs to cause overflow
  921. cy.then(() => selectDoc(DOC_IDS.main))
  922. for (let i = 1; i <= 10; i++) {
  923. const id = `ch${i}`
  924. cy.then(() => selectEntity(makeDocEntity(id, `chapter-${i}.tex`)))
  925. }
  926. // Scroll back to the start
  927. cy.findByRole('tablist').then($el => {
  928. $el[0].scrollLeft = 0
  929. })
  930. // Trigger a vertical wheel event on the tablist
  931. cy.findByRole('tablist').trigger('wheel', { deltaY: 100, deltaX: 0 })
  932. // scrollLeft should have increased
  933. cy.findByRole('tablist').should($el => {
  934. expect($el[0].scrollLeft).to.be.greaterThan(0)
  935. })
  936. })
  937. })
  938. describe('Pruning deleted files', function () {
  939. it('prunes persisted tabs whose files are no longer in the tree', function () {
  940. cy.then(() => selectDoc(DOC_IDS.main))
  941. cy.then(() => selectDoc(DOC_IDS.intro))
  942. cy.then(() => selectDoc(DOC_IDS.appendix))
  943. cy.findAllByRole('tab').should('have.length', 3)
  944. // Re-mount with a tree containing only appendix.tex, then verify
  945. // the last remaining tab cannot be closed
  946. const trimmedRootFolder = makeRootFolder([
  947. { _id: DOC_IDS.appendix, name: 'appendix.tex' },
  948. ])
  949. mountTabs({ rootFolder: trimmedRootFolder })
  950. cy.findAllByRole('tab').should('have.length', 1)
  951. cy.findByRole('tab', { name: /appendix\.tex/ }).should('exist')
  952. cy.findByRole('tab', { name: /appendix\.tex/ }).within(() => {
  953. cy.findByRole('button', { name: 'Close' }).click()
  954. })
  955. cy.findAllByRole('tab').should('have.length', 1)
  956. cy.findByRole('tab', { name: /appendix\.tex/ }).should('exist')
  957. })
  958. })
  959. describe('SplitTestBadge', function () {
  960. it('renders the labs badge icon in the tabs container', function () {
  961. cy.window().then(win => {
  962. win.metaAttributesCache.set('ol-splitTestInfo', {
  963. 'editor-tabs': {
  964. phase: 'beta',
  965. badgeInfo: {
  966. url: '/beta/editor-tabs',
  967. tooltipText: 'Editor tabs are in beta',
  968. },
  969. },
  970. })
  971. })
  972. mountTabs()
  973. cy.then(() => selectDoc(DOC_IDS.main))
  974. cy.get('.editor-tabs-labs-icon').should('exist')
  975. })
  976. })
  977. })