text-preview.client.spec.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. // @vitest-environment jsdom
  2. /**
  3. * What the body draws from its pages and the file's metadata, and what it does
  4. * with a navigation: load until the asked line is held, jump to it once, then
  5. * keep the reader's place.
  6. *
  7. * jsdom lays nothing out, so two geometry facts are supplied here: a line's
  8. * offset is its number times one line height, and `scrollTop` holds what it is
  9. * set to. Both are the browser's job; the specs assert the body's arithmetic
  10. * over them.
  11. */
  12. import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
  13. import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
  14. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  15. import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
  16. import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
  17. import { TextPreview } from '../src/client/TextPreview.tsx'
  18. import type { TextPreviewProps } from '../src/client/TextPreview.tsx'
  19. import { CodeBody } from '../src/client/code/CodeBody.tsx'
  20. import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
  21. import { TextBody } from '../src/client/text/TextBody.tsx'
  22. import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
  23. import { ABSOLUTE_PATH, ADDRESS, PATH, SESSION, TAB_ID, failure, harness, page, settle } from './fixtures.client.ts'
  24. const LINE_HEIGHT = 20
  25. const CODE_TOOLBAR_HEIGHT = 36
  26. const originals = {
  27. offsetTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop'),
  28. offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'),
  29. scrollTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'),
  30. }
  31. beforeAll(() => {
  32. Object.defineProperty(HTMLElement.prototype, 'offsetTop', {
  33. configurable: true,
  34. get(this: HTMLElement) {
  35. const line = this.getAttribute('data-textpreview-line')
  36. if (line !== null) return (Number(line) - 1) * LINE_HEIGHT
  37. if (!this.matches('[data-code-preview] pre .line')) return 0
  38. const rows = this.closest('[data-code-preview]')?.querySelectorAll('pre .line') ?? []
  39. return Array.from(rows).indexOf(this) * LINE_HEIGHT
  40. },
  41. })
  42. Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
  43. configurable: true,
  44. get(this: HTMLElement & { __scrollTop?: number }) { return this.__scrollTop ?? 0 },
  45. set(this: HTMLElement & { __scrollTop?: number }, value: number) { this.__scrollTop = value },
  46. })
  47. Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
  48. configurable: true,
  49. get(this: HTMLElement) {
  50. return this.parentElement?.classList.contains('md-code-block') === true
  51. && this.parentElement.firstElementChild === this ? CODE_TOOLBAR_HEIGHT : 0
  52. },
  53. })
  54. })
  55. afterAll(() => {
  56. for (const [name, descriptor] of Object.entries(originals)) {
  57. if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name)
  58. else Object.defineProperty(HTMLElement.prototype, name, descriptor)
  59. }
  60. })
  61. afterEach(() => {
  62. cleanup()
  63. vi.unstubAllGlobals()
  64. })
  65. class PendingIntersectionObserver {
  66. static instances: PendingIntersectionObserver[] = []
  67. readonly observed = new Set<Element>()
  68. constructor(private readonly callback: IntersectionObserverCallback) {
  69. PendingIntersectionObserver.instances.push(this)
  70. }
  71. observe(element: Element): void { this.observed.add(element) }
  72. unobserve(element: Element): void { this.observed.delete(element) }
  73. disconnect(): void {}
  74. takeRecords(): IntersectionObserverEntry[] { return [] }
  75. intersect(element: Element): void {
  76. this.callback(
  77. [{ target: element, isIntersecting: true } as IntersectionObserverEntry],
  78. this as unknown as IntersectionObserver,
  79. )
  80. }
  81. }
  82. function codeProps(h: ReturnType<typeof harness>, navigation: { params?: unknown; revision: number }): TextPreviewProps {
  83. const props = h.props(navigation)
  84. const definition: DocumentPreviewDefinition = {
  85. id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true,
  86. }
  87. return {
  88. ...props,
  89. useDocumentPreviews: selector => selector([definition]),
  90. renderSlot: (_key, owner) => <CodeBody {...props} {...owner as unknown as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />,
  91. }
  92. }
  93. function body(container: HTMLElement): HTMLElement {
  94. const element = container.querySelector<HTMLElement>('[data-textpreview-body]')
  95. if (element === null) throw new Error('expected the file body')
  96. return element
  97. }
  98. function lines(container: HTMLElement): string[] {
  99. return Array.from(container.querySelectorAll('[data-textpreview-line]'), row => row.textContent ?? '')
  100. }
  101. function target(container: HTMLElement): string | null {
  102. return container.querySelector('[data-textpreview-target]')?.getAttribute('data-textpreview-target') ?? null
  103. }
  104. function click(container: HTMLElement, selector: string): void {
  105. const button = container.querySelector<HTMLButtonElement>(selector)
  106. if (button === null) throw new Error(`expected ${selector}`)
  107. fireEvent.click(button)
  108. }
  109. describe('TextPreview — pages', () => {
  110. it.each([ABSOLUTE_PATH, 'C:\\work\\project\\notes.md', '\\\\host\\share\\notes.md'])(
  111. 'shows the Host path %s in the header and tooltip even when text cannot be read',
  112. async (absolutePath) => {
  113. const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) })
  114. h.useResource.mockReturnValue({
  115. status: 'live', value: { absolutePath, version: 'v1', bytes: 100 }, failure: undefined,
  116. })
  117. const view = render(<TextPreview {...h.props()} />)
  118. await settle()
  119. const path = view.container.querySelector('[data-textpreview-path]')
  120. expect(path?.textContent).toBe(absolutePath)
  121. expect(path?.getAttribute('title')).toBe(absolutePath)
  122. expect(h.read).toHaveBeenCalledWith(SESSION, PATH, 1, h.controller.signal)
  123. },
  124. )
  125. it('shows the requested path until Host metadata supplies its absolute path', async () => {
  126. const h = harness({ 1: page(1, ['one'], true) })
  127. const metadata = h.useResource()
  128. h.useResource.mockReturnValue({ status: 'loading', value: undefined, failure: undefined })
  129. const view = render(<TextPreview {...h.props()} />)
  130. await settle()
  131. expect(view.container.querySelector('[data-textpreview-path]')?.textContent).toBe(PATH)
  132. h.useResource.mockReturnValue(metadata)
  133. view.rerender(<TextPreview {...h.props()} />)
  134. expect(view.container.querySelector('[data-textpreview-path]')?.textContent).toBe(ABSOLUTE_PATH)
  135. expect(view.container.querySelector('[data-textpreview-path]')?.getAttribute('title')).toBe(ABSOLUTE_PATH)
  136. })
  137. it('marks the path clipped while its text is wider than its box, re-reading on resize', async () => {
  138. class FakeResizeObserver implements ResizeObserver {
  139. static latest: FakeResizeObserver | undefined
  140. readonly observe = vi.fn()
  141. readonly unobserve = vi.fn()
  142. readonly disconnect = vi.fn()
  143. constructor(private readonly callback: ResizeObserverCallback) {
  144. FakeResizeObserver.latest = this
  145. }
  146. fire(): void {
  147. this.callback([], this)
  148. }
  149. }
  150. vi.stubGlobal('ResizeObserver', FakeResizeObserver)
  151. let boxWidth = 300
  152. const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')
  153. const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
  154. Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
  155. Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => boxWidth })
  156. try {
  157. const h = harness({ 1: page(1, ['one'], true) })
  158. const view = render(<TextPreview {...h.props()} />)
  159. await settle()
  160. const path = view.container.querySelector<HTMLElement>('[data-textpreview-path]')
  161. const text = path?.firstElementChild
  162. expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(false)
  163. const observer = FakeResizeObserver.latest
  164. if (observer === undefined) throw new Error('expected the path to observe its size')
  165. expect(observer.observe).toHaveBeenCalledWith(path)
  166. expect(observer.observe).toHaveBeenCalledWith(text)
  167. boxWidth = 120
  168. act(() => { observer.fire() })
  169. expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(true)
  170. boxWidth = 300
  171. act(() => { observer.fire() })
  172. expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(false)
  173. view.unmount()
  174. expect(observer.disconnect).toHaveBeenCalledTimes(1)
  175. } finally {
  176. vi.unstubAllGlobals()
  177. for (const [name, descriptor] of [['offsetWidth', offsetWidth], ['clientWidth', clientWidth]] as const) {
  178. if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name)
  179. else Object.defineProperty(HTMLElement.prototype, name, descriptor)
  180. }
  181. }
  182. })
  183. it('reads the first page on first mount and draws its lines, offering the next', async () => {
  184. const h = harness({ 1: page(1, ['one', 'two', 'three'], false) })
  185. const view = render(<TextPreview {...h.props()} />)
  186. // The first read has no document body or next-page control to displace its status.
  187. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  188. expect(view.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
  189. expect(body(view.container).firstElementChild).toBe(view.getByRole('status'))
  190. expect(lines(view.container)).toEqual([])
  191. await settle()
  192. expect(view.queryByRole('status')).toBeNull()
  193. expect(h.read).toHaveBeenCalledTimes(1)
  194. expect(h.read).toHaveBeenCalledWith(SESSION, PATH, 1, h.controller.signal)
  195. expect(lines(view.container)).toEqual(['one\n', 'two\n', 'three\n'])
  196. expect(view.container.querySelector('[data-textpreview-url]')?.getAttribute('data-textpreview-url')).toBe(ADDRESS)
  197. expect(view.container.textContent).toContain(PATH)
  198. expect(view.container.querySelector('[data-textpreview-more]')).not.toBeNull()
  199. expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
  200. })
  201. it('reads nothing on a remount while the store holds the pages', async () => {
  202. const h = harness({ 1: page(1, ['one'], true) })
  203. const first = render(<TextPreview {...h.props()} />)
  204. await settle()
  205. first.unmount()
  206. const second = render(<TextPreview {...h.props()} />)
  207. await settle()
  208. expect(h.read).toHaveBeenCalledTimes(1)
  209. expect(lines(second.container)).toEqual(['one\n'])
  210. })
  211. it('loads the next page where the loaded text ends, until the file ends', async () => {
  212. const h = harness({ 1: page(1, ['a', 'b', 'c'], false), 4: page(4, ['d', 'e'], true) })
  213. const view = render(<TextPreview {...h.props()} />)
  214. await settle()
  215. click(view.container, '[data-textpreview-more]')
  216. await settle()
  217. expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 4, h.controller.signal)
  218. expect(lines(view.container)).toEqual(['a\n', 'b\n', 'c\n', 'd\n', 'e\n'])
  219. expect(view.container.querySelectorAll('[data-textpreview-page]').length).toBe(2)
  220. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  221. })
  222. it('keeps loaded lines visible while the next page shows the shared loading indicator', async () => {
  223. const h = harness({ 1: page(1, ['held'], false) })
  224. const view = render(<TextPreview {...h.props()} />)
  225. await settle()
  226. const next = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
  227. h.read.mockReturnValueOnce(next.promise)
  228. click(view.container, '[data-textpreview-more]')
  229. expect(view.getByRole('status').textContent).toBe('loading')
  230. expect(lines(view.container)).toEqual(['held\n'])
  231. await act(async () => { next.resolve(page(2, ['tail'], true)); await next.promise })
  232. expect(view.queryByRole('status')).toBeNull()
  233. expect(lines(view.container)).toEqual(['held\n', 'tail\n'])
  234. })
  235. it('says why a page failed and retries the same page', async () => {
  236. const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) })
  237. const view = render(<TextPreview {...h.props()} />)
  238. await settle()
  239. const failed = view.container.querySelector('[data-textpreview-failed]')
  240. expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text')
  241. expect(view.container.textContent).toContain('error.notText')
  242. // Nothing read yet: the failure stands as the body, under the file's type sheet.
  243. expect(failed?.querySelector('svg')).not.toBeNull()
  244. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  245. h.script(1, page(1, ['one'], true))
  246. click(view.container, '[data-textpreview-retry]')
  247. await settle()
  248. expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal)
  249. expect(lines(view.container)).toEqual(['one\n'])
  250. expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
  251. })
  252. it('says why a later page failed on a line under the pages already read', async () => {
  253. const h = harness({ 1: page(1, ['a'], false), 2: failure('workspace-file/too-large', { path: PATH, limit: 1024 }) })
  254. const view = render(<TextPreview {...h.props()} />)
  255. await settle()
  256. click(view.container, '[data-textpreview-more]')
  257. await settle()
  258. const failed = view.container.querySelector('[data-textpreview-failed]')
  259. expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/too-large')
  260. expect(failed?.querySelector('svg')).toBeNull()
  261. expect(lines(view.container)).toEqual(['a\n'])
  262. h.script(2, page(2, ['b'], true))
  263. click(view.container, '[data-textpreview-retry]')
  264. await settle()
  265. expect(lines(view.container)).toEqual(['a\n', 'b\n'])
  266. expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
  267. })
  268. it('announces a change and, on request, re-reads the pages keeping the reader\'s place', async () => {
  269. const h = harness({ 1: page(1, ['a', 'b'], true) })
  270. const view = render(<TextPreview {...h.props()} />)
  271. await settle()
  272. fireEvent.scroll(body(view.container), { target: { scrollTop: 50 } })
  273. h.setVersion('v2')
  274. view.rerender(<TextPreview {...h.props()} />)
  275. expect(view.container.querySelector('[data-textpreview-changed]')?.textContent).toContain('changed')
  276. expect(lines(view.container)).toEqual(['a\n', 'b\n'])
  277. h.script(1, page(1, ['A', 'B', 'C'], true, 'v2'))
  278. click(view.container, '[data-textpreview-reload-now]')
  279. expect(h.read).toHaveBeenCalledTimes(2)
  280. await settle()
  281. expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal)
  282. expect(lines(view.container)).toEqual(['A\n', 'B\n', 'C\n'])
  283. expect(body(view.container).scrollTop).toBe(50)
  284. })
  285. })
  286. describe('TextPreview — the file\'s metadata', () => {
  287. it('does not treat the observation present at read start as a later file change', async () => {
  288. const h = harness({ 1: page(1, ['newer read'], true, 'v2') })
  289. const view = render(<TextPreview {...h.props()} />)
  290. await settle()
  291. expect(h.instance.getSnapshot().byTab[TAB_ID]).toMatchObject({ version: 'v2', observedVersion: 'v1' })
  292. expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
  293. h.setVersion('v3')
  294. view.rerender(<TextPreview {...h.props()} />)
  295. expect(view.container.querySelector('[data-textpreview-changed]')).not.toBeNull()
  296. h.script(1, page(1, ['refreshed'], true, 'v4'))
  297. click(view.container, '[data-textpreview-reload-now]')
  298. await settle()
  299. expect(h.instance.getSnapshot().byTab[TAB_ID]).toMatchObject({ version: 'v4', observedVersion: 'v3' })
  300. expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
  301. h.setVersion('v5')
  302. view.rerender(<TextPreview {...h.props()} />)
  303. expect(view.container.querySelector('[data-textpreview-changed]')).not.toBeNull()
  304. })
  305. it('announces metadata that changes while the first content read is still pending', async () => {
  306. const h = harness()
  307. const pending = Promise.withResolvers<ReturnType<typeof page>>()
  308. h.read.mockReturnValueOnce(pending.promise)
  309. const view = render(<TextPreview {...h.props()} />)
  310. h.setVersion('v2')
  311. view.rerender(<TextPreview {...h.props()} />)
  312. await act(async () => { pending.resolve(page(1, ['read v1'], true)); await pending.promise })
  313. expect(h.read).toHaveBeenCalledTimes(1)
  314. expect(h.instance.getSnapshot().byTab[TAB_ID]).toMatchObject({ version: 'v1', observedVersion: 'v1' })
  315. expect(view.container.querySelector('[data-textpreview-changed]')).not.toBeNull()
  316. })
  317. it('refreshes one tab without acknowledging another tab on the same file and store', async () => {
  318. const h = harness({ 1: page(1, ['old'], true) })
  319. const otherId = 'tab-2' as TabId
  320. const other = harness({}, otherId)
  321. const firstProps = h.props()
  322. const secondProps = { ...firstProps, useTabInfo: other.props().useTabInfo }
  323. const first = render(<TextPreview {...firstProps} />)
  324. const second = render(<TextPreview {...secondProps} />)
  325. await settle()
  326. h.setVersion('v2')
  327. first.rerender(<TextPreview {...firstProps} />)
  328. second.rerender(<TextPreview {...secondProps} />)
  329. expect(first.container.querySelector('[data-textpreview-changed]')).not.toBeNull()
  330. expect(second.container.querySelector('[data-textpreview-changed]')).not.toBeNull()
  331. h.script(1, page(1, ['new'], true, 'v2'))
  332. click(first.container, '[data-textpreview-reload-now]')
  333. await settle()
  334. expect(first.container.querySelector('[data-textpreview-changed]')).toBeNull()
  335. expect(second.container.querySelector('[data-textpreview-changed]')).not.toBeNull()
  336. expect(lines(first.container)).toEqual(['new\n'])
  337. expect(lines(second.container)).toEqual(['old\n'])
  338. expect(h.instance.getSnapshot().byTab[TAB_ID]).toMatchObject({ version: 'v2', observedVersion: 'v2' })
  339. expect(h.instance.getSnapshot().byTab[otherId]).toMatchObject({ version: 'v1', observedVersion: 'v1' })
  340. expect(h.file?.version).toBe('v2')
  341. })
  342. it('keeps metadata failure separate from per-tab content refresh', async () => {
  343. const h = harness({ 1: page(1, ['a', 'b'], true) })
  344. const view = render(<TextPreview {...h.props()} />)
  345. await settle()
  346. h.setVersion('v2')
  347. h.setFailure(new RemoteError('workspace-file/not-found', 'gone', { path: PATH }))
  348. view.rerender(<TextPreview {...h.props()} />)
  349. const bar = view.container.querySelector('[data-textpreview-meta-failed]')
  350. expect(bar?.getAttribute('data-textpreview-meta-failed')).toBe('workspace-file/not-found')
  351. expect(bar?.textContent).toContain('error.notFound')
  352. expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
  353. expect(lines(view.container)).toEqual(['a\n', 'b\n'])
  354. // Retrying content does not acknowledge or mutate the shared metadata failure.
  355. h.script(1, page(1, ['A'], true, 'v2'))
  356. click(view.container, '[data-textpreview-reload-now]')
  357. expect(h.read).toHaveBeenCalledTimes(2)
  358. await settle()
  359. expect(lines(view.container)).toEqual(['A\n'])
  360. // A later provider frame independently clears the metadata failure.
  361. h.setVersion('v2')
  362. h.setFailure(undefined)
  363. view.rerender(<TextPreview {...h.props()} />)
  364. expect(view.container.querySelector('[data-textpreview-meta-failed]')).toBeNull()
  365. expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
  366. })
  367. it('says a metadata-and-read failure once and retries the content read', async () => {
  368. const h = harness({ 1: failure('workspace-file/outside-workspace', { path: PATH }) })
  369. h.setFailure(new RemoteError('workspace-file/outside-workspace', 'outside', { path: PATH }))
  370. const view = render(<TextPreview {...h.props()} />)
  371. await settle()
  372. // With nothing read the body's failure is the whole story: a metadata bar
  373. // above it would repeat the same line.
  374. expect(view.container.querySelector('[data-textpreview-meta-failed]')).toBeNull()
  375. expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed'))
  376. .toBe('workspace-file/outside-workspace')
  377. h.script(1, page(1, ['a'], true))
  378. click(view.container, '[data-textpreview-retry]')
  379. await settle()
  380. expect(h.read).toHaveBeenCalledTimes(2)
  381. expect(lines(view.container)).toEqual(['a\n'])
  382. h.setFailure(undefined)
  383. view.rerender(<TextPreview {...h.props()} />)
  384. expect(view.container.querySelector('[data-textpreview-meta-failed]')).toBeNull()
  385. expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
  386. })
  387. it('draws a page holding one empty line as one line, and nothing for a page past the end', async () => {
  388. const h = harness({ 1: page(1, [''], false), 2: page(2, [], true) })
  389. const view = render(<TextPreview {...h.props()} />)
  390. await settle()
  391. expect(lines(view.container)).toEqual(['\n'])
  392. click(view.container, '[data-textpreview-more]')
  393. await settle()
  394. expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 2, h.controller.signal)
  395. expect(lines(view.container)).toEqual(['\n'])
  396. expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
  397. })
  398. })
  399. describe('TextPreview — navigation and view', () => {
  400. it.each([
  401. ['Code', 'code', 2 * LINE_HEIGHT - CODE_TOOLBAR_HEIGHT],
  402. ['Plain text', PLAIN_BODY_ID, 2 * LINE_HEIGHT],
  403. ])('retries a Markdown line navigation after switching to %s', async (_name, rendererId, expectedScrollTop) => {
  404. PendingIntersectionObserver.instances = []
  405. vi.stubGlobal('IntersectionObserver', PendingIntersectionObserver)
  406. const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
  407. const definitions: DocumentPreviewDefinition[] = [
  408. { id: 'markdown', extensions: ['md'], title: () => 'Markdown', loading: 'text-pages', wrap: false },
  409. { id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true },
  410. { id: PLAIN_BODY_ID, extensions: [], title: () => 'Plain text', loading: 'text-pages', wrap: true },
  411. ]
  412. const base = h.props({ params: { line: 3 }, revision: 1 })
  413. const props: TextPreviewProps = {
  414. ...base,
  415. useDocumentPreviews: selector => selector(definitions),
  416. renderSlot: (_key, owner, opts) => {
  417. const documentOwner = owner as unknown as OwnerOf<'sidebar.right.tab.document'>
  418. if (opts.entryKey === 'code') return <CodeBody {...base} {...documentOwner} t={key => key} />
  419. if (opts.entryKey === PLAIN_BODY_ID) return <TextBody {...base} {...documentOwner} />
  420. return <div data-test-no-lines />
  421. },
  422. }
  423. const view = render(<TextPreview {...props} />)
  424. await settle()
  425. expect(body(view.container).scrollTop).toBe(0)
  426. expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBeUndefined()
  427. act(() => { h.instance.actions.selected(TAB_ID, rendererId) })
  428. await waitFor(() => { expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1) })
  429. expect(body(view.container).scrollTop).toBe(expectedScrollTop)
  430. })
  431. it('lands on code lines before and after syntax highlighting is ready', async () => {
  432. PendingIntersectionObserver.instances = []
  433. vi.stubGlobal('IntersectionObserver', PendingIntersectionObserver)
  434. const h = harness({ 1: page(1, ['const a = 1', 'const b = 2', 'const c = 3'], true) })
  435. const view = render(<TextPreview {...codeProps(h, { params: { line: 2 }, revision: 1 })} />)
  436. await settle()
  437. expect(view.container.querySelector('[data-code-preview] pre.shiki')).toBeNull()
  438. expect(view.container.querySelectorAll('[data-code-preview] pre .line')).toHaveLength(3)
  439. expect(body(view.container).scrollTop).toBe(0)
  440. expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1)
  441. const block = view.container.querySelector('[data-code-preview] .md-code-block')!
  442. act(() => { PendingIntersectionObserver.instances[0]!.intersect(block) })
  443. await waitFor(() => { expect(view.container.querySelector('[data-code-preview] pre.shiki')).not.toBeNull() })
  444. view.rerender(<TextPreview {...codeProps(h, { params: { line: 3 }, revision: 2 })} />)
  445. expect(body(view.container).scrollTop).toBe(2 * LINE_HEIGHT - CODE_TOOLBAR_HEIGHT)
  446. expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2)
  447. })
  448. it('loads until the navigated line is held, then jumps to it once and marks it', async () => {
  449. const h = harness({ 1: page(1, ['a', 'b', 'c'], false), 4: page(4, ['d', 'e', 'f'], true) })
  450. const view = render(<TextPreview {...h.props({ params: { line: 5 }, revision: 1 })} />)
  451. await settle()
  452. // The first page does not reach line 5, so the body asks for the next on its own.
  453. await settle()
  454. expect(h.read).toHaveBeenCalledTimes(2)
  455. expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 4, h.controller.signal)
  456. expect(body(view.container).scrollTop).toBe(4 * LINE_HEIGHT)
  457. expect(target(view.container)).toBe('5')
  458. expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1)
  459. expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(4 * LINE_HEIGHT)
  460. })
  461. it('comes back where the reader was on a remount, instead of jumping again', async () => {
  462. const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
  463. const first = render(<TextPreview {...h.props({ params: { line: 3 }, revision: 1 })} />)
  464. await settle()
  465. expect(body(first.container).scrollTop).toBe(2 * LINE_HEIGHT)
  466. fireEvent.scroll(body(first.container), { target: { scrollTop: 300 } })
  467. first.unmount()
  468. const second = render(<TextPreview {...h.props({ params: { line: 3 }, revision: 1 })} />)
  469. await settle()
  470. expect(body(second.container).scrollTop).toBe(300)
  471. })
  472. it('jumps again for a new navigation to the same tab', async () => {
  473. const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
  474. const view = render(<TextPreview {...h.props({ params: { line: 3 }, revision: 1 })} />)
  475. await settle()
  476. fireEvent.scroll(body(view.container), { target: { scrollTop: 300 } })
  477. view.rerender(<TextPreview {...h.props({ params: { line: 2 }, revision: 2 })} />)
  478. expect(body(view.container).scrollTop).toBe(1 * LINE_HEIGHT)
  479. expect(target(view.container)).toBe('2')
  480. expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2)
  481. })
  482. it('stops at the end of the file for a line past it, and answers a navigation without a line', async () => {
  483. const h = harness({ 1: page(1, ['a', 'b'], true) })
  484. const view = render(<TextPreview {...h.props({ params: { line: 99 }, revision: 1 })} />)
  485. await settle()
  486. expect(h.read).toHaveBeenCalledTimes(1)
  487. expect(target(view.container)).toBeNull()
  488. expect(body(view.container).scrollTop).toBe(0)
  489. expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1)
  490. view.rerender(<TextPreview {...h.props({ params: {}, revision: 2 })} />)
  491. expect(target(view.container)).toBeNull()
  492. expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2)
  493. })
  494. it('wraps by default and stops when the shared store says so', async () => {
  495. const h = harness({ 1: page(1, ['a'], true) })
  496. const view = render(<TextPreview {...h.props()} />)
  497. await settle()
  498. expect(body(view.container).hasAttribute('data-textpreview-wrap')).toBe(true)
  499. act(() => { h.instance.actions.toggledWrap(TAB_ID) })
  500. expect(body(view.container).hasAttribute('data-textpreview-wrap')).toBe(false)
  501. })
  502. })
  503. describe('TextPreview — header controls', () => {
  504. it('toggles wrap off from the header, reporting the pressed state', async () => {
  505. const h = harness({ 1: page(1, ['a'], true) })
  506. const view = render(<TextPreview {...h.props()} />)
  507. await settle()
  508. const wrap = view.container.querySelector<HTMLButtonElement>('[data-textpreview-tool="wrap"]')
  509. if (wrap === null) throw new Error('expected the wrap control')
  510. expect(wrap.getAttribute('aria-pressed')).toBe('true')
  511. fireEvent.click(wrap)
  512. expect(h.instance.getSnapshot().byTab[TAB_ID]?.wrap).toBe(false)
  513. expect(wrap.getAttribute('aria-pressed')).toBe('false')
  514. expect(body(view.container).hasAttribute('data-textpreview-wrap')).toBe(false)
  515. })
  516. it('reloads only this tab from the header without a change announced', async () => {
  517. const h = harness({ 1: page(1, ['one'], true) })
  518. const view = render(<TextPreview {...h.props()} />)
  519. await settle()
  520. expect(h.useResource).toHaveBeenCalledWith(ADDRESS)
  521. expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
  522. h.script(1, page(1, ['uno'], true, 'v2'))
  523. click(view.container, '[data-textpreview-tool="reload"]')
  524. expect(h.read).toHaveBeenCalledTimes(2)
  525. await settle()
  526. expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal)
  527. expect(lines(view.container)).toEqual(['uno\n'])
  528. })
  529. it('forgets at once when mounted for a record that has already ended', async () => {
  530. const h = harness({ 1: page(1, ['a'], true) })
  531. h.controller.abort()
  532. render(<TextPreview {...h.props()} />)
  533. await settle()
  534. expect(h.instance.getSnapshot().byTab[TAB_ID]).toBeUndefined()
  535. })
  536. it('forgets its state when the record ends, even with the body unmounted, through one listener however often it mounted', async () => {
  537. const h = harness({ 1: page(1, ['a'], true) })
  538. const armed = vi.spyOn(h.controller.signal, 'addEventListener')
  539. const first = render(<TextPreview {...h.props()} />)
  540. await settle()
  541. expect(h.instance.getSnapshot().byTab[TAB_ID]).toBeDefined()
  542. // Switched away and back: the store outlives the body, so nothing re-arms.
  543. first.unmount()
  544. const second = render(<TextPreview {...h.props()} />)
  545. await settle()
  546. second.unmount()
  547. expect(armed.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(1)
  548. h.controller.abort()
  549. expect(h.instance.getSnapshot().byTab[TAB_ID]).toBeUndefined()
  550. })
  551. })