tabs.spec.tsx 38 KB

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