text-preview.client.spec.tsx 31 KB

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