Merge pull request #4921 from deepseek-harness/fix/4918-excel-preview-resize

fix(web): resize Excel previews with their pane
This commit is contained in:
Yudong Han authored and GitHub committed 2026-09-22 16:46:26 +08:00
commit d3c5f959aa
6 files changed
+94 -8

No files matched your search

+52
View File
@@ -104,6 +104,58 @@ async function hideDocumentZoom(page: Page, preview: Locator): Promise<void> {
await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull()
}
it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while retaining the selected cell', async () => {
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
let browser: Browser | undefined
try {
browser = await chromium.launch()
const page = await newEnglishPage(browser)
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${process.pid}`) })
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
await writeFile(join(cwd, 'budget.xlsx'), await excelFixture())
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
const column = page.locator('[data-rightbar-col]')
await page.locator('[data-sidebar-right-expand]').click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'budget.xlsx', exact: true }).click()
const excel = column.locator('[data-excel-preview]')
await excel.locator('.luckysheet-sheets-item-name').getByText('公式与格式', { exact: true }).click()
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 140, y: 30 } })
const formula = excel.locator('.fortune-fx-input')
await expect.poll(() => formula.innerText()).toBe('46281')
const canvas = excel.locator('canvas').first()
const originalCanvas = await canvas.elementHandle()
const original = await canvas.boundingBox()
if (original === null || originalCanvas === null) throw new Error('spreadsheet canvas is unavailable')
const panel = page.locator('[data-sidebar-right-panel]')
const panelBounds = await panel.boundingBox()
if (panelBounds === null) throw new Error('sidebar panel is unavailable')
const viewport = page.viewportSize()
const layout = await page.addStyleTag({ content: `[data-sidebar-right-panel] { width: ${panelBounds.width + 160}px !important; }` })
await expect.poll(async () => Math.round((await canvas.boundingBox())!.width - original.width)).toBe(160)
await expect.poll(() => formula.innerText()).toBe('46281')
expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true)
expect(page.viewportSize()).toEqual(viewport)
await successShot(page, 'excel-resize-wide')
await layout.evaluate((node) => { node.textContent = '' })
await expect.poll(async () => Math.round((await canvas.boundingBox())!.width)).toBe(Math.round(original.width))
expect(await formula.innerText()).toBe('46281')
await successShot(page, 'excel-resize-restored')
await originalCanvas.dispose()
} finally {
try { await browser?.close() } finally { await scaffold.close() }
}
})
describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md
README.md: 224957fec45fd4d2849df677b9cada62d01d18cd
README.zh.md: 18fe9e94617291d85fd3db7bf1c7bcc347f6ec73
README.md: c238b30dd87a72d4a2a0ed4f29498f72c32c61b8
README.zh.md: 559f6e337c3aadd29d3551f64c59307073461808
@@ -66,7 +66,7 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin
<a id="excel-preview"></a>
## Excel preview
Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. Spreadsheet preview does not call the Office conversion service.
Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The workbook viewport follows preview pane size changes without reloading its sheets or selection. Spreadsheet preview does not call the Office conversion service.
XLSX preview omits DrawingML parts from an in-memory copy before parsing and ignores worksheet drawing references. The original file and worksheet XML remain unchanged. A notice above the table lists detected charts, images, shapes, and conditional formatting that are not displayed, and recommends opening the workbook in a system application. Files without these detected features have no notice; formula warnings remain separate.
@@ -66,7 +66,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
<a id="excel-preview"></a>
## Excel 预览
直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。表格预览不调用 Office 转换服务。
直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。工作簿视图随预览面板尺寸变化自动调整,无需重新加载工作表或选择状态。表格预览不调用 Office 转换服务。
XLSX 预览在解析前从内存副本中移除 DrawingML 部件,并忽略工作表的绘图引用。原文件和工作表 XML 保持不变。表格上方的提示列出实际检测到但未展示的图表、图片、形状和条件格式,并建议使用系统应用打开工作簿。未检测到这些内容的文件不显示此提示;公式提示保持独立。
@@ -1,5 +1,5 @@
/** Read-only spreadsheet surface backed by browser-parsed workbook data. */
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Workbook } from '@fortune-sheet/react'
import { Button, IconWarningTriangleOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import fortuneCss from '@fortune-sheet/react/dist/index.css?inline'
@@ -14,6 +14,7 @@ const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }`
/**
* Display stored spreadsheet values and formats without editing or recalculation.
* Keep the mounted workbook sized to its preview pane.
* @param props - Complete workbook bytes, limits, and locale.
* @returns An isolated spreadsheet surface with cancellable loading.
*/
@@ -21,6 +22,7 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
const data = content.kind === 'bytes' ? content.data : undefined
const [state, setState] = useState<State>()
const [attempt, setAttempt] = useState(0)
const workbookRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (data === undefined) return
const controller = new AbortController()
@@ -31,6 +33,14 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
)
return () => { controller.abort() }
}, [data, format, limits, attempt])
useEffect(() => {
const element = workbookRef.current
if (element === null || typeof ResizeObserver === 'undefined') return
// FortuneSheet measures its canvas on window resize, but pane resizing does not emit that event.
const observer = new ResizeObserver(() => { window.dispatchEvent(new Event('resize')) })
observer.observe(element)
return () => { observer.disconnect() }
}, [state])
if (data === undefined) return <p className={css.status} role="alert">{t('invalid')}</p>
if (state?.data !== data || state.format !== format) return <span className={css.status} role="status" aria-label={t('loading')} data-document-loading>
<StateDot state="ongoing" />
@@ -46,7 +56,7 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
<IconWarningTriangleOutlineRegular size={16} />
<span>{t('unsupportedNotice', { features: state.value.unsupportedFeatures.map(feature => t(feature)).join(t('featureSeparator')) })}</span>
</div>}
<div className={`${css.workbook} ${hasFormulas ? css.withFormulaWarning : ''}`}>
<div ref={workbookRef} className={`${css.workbook} ${hasFormulas ? css.withFormulaWarning : ''}`}>
{hasFormulas && <Tooltip portal label={t('formulaWarning')} side="bottom" delayMs={500}>
<button type="button" className={css.formulaWarning} aria-label={t('formulaWarning')} data-excel-formula-warning>
<IconWarningTriangleOutlineRegular size={14} />
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/** Excel preview lifecycle and read-only renderer settings. */
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { Config } from '../src/config.ts'
@@ -17,7 +17,31 @@ const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: C
const loadedProps = { ...props, format: 'xlsx' } as LoadedExcelBodyProps
const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0, unsupportedFeatures: [] }
const formulaValue = { sheets: [{ name: 'Budget', celldata: [{ r: 0, c: 0, v: { f: '=SUM(1,2)', m: '' } }] }], missingResults: 1, unsupportedFeatures: [] }
afterEach(() => { cleanup(); vi.resetAllMocks() })
afterEach(() => { cleanup(); vi.resetAllMocks(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
it('refreshes the existing workbook on pane resize and disconnects on file replacement and unmount', async () => {
const observers: { callback: ResizeObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = []
vi.stubGlobal('ResizeObserver', class {
observe = vi.fn()
disconnect = vi.fn()
constructor(callback: ResizeObserverCallback) { observers.push({ callback, observe: this.observe, disconnect: this.disconnect }) }
})
const dispatch = vi.spyOn(window, 'dispatchEvent')
mocked.parse.mockResolvedValue(value)
const view = render(<ExcelBody {...loadedProps} />)
await waitFor(() => { expect(observers).toHaveLength(1) })
const first = observers[0]!
expect(first.observe).toHaveBeenCalledWith(view.container.querySelector('[data-excel-preview] > div'))
act(() => { first.callback([], {} as ResizeObserver) })
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'resize' }))
expect(mocked.workbook).toHaveBeenCalledOnce()
expect(mocked.parse).toHaveBeenCalledOnce()
view.rerender(<ExcelBody {...loadedProps} content={{ kind: 'bytes', data: new Uint8Array([2]) }} />)
await waitFor(() => { expect(observers).toHaveLength(2) })
expect(first.disconnect).toHaveBeenCalledOnce()
view.unmount()
expect(observers[1]!.disconnect).toHaveBeenCalledOnce()
})
it('shows loading then a workbook with editing and recalculation disabled', async () => {
mocked.parse.mockResolvedValue(formulaValue)