perf: tag the center column instead of a :has() selector; stop re-stringifying the editor doc per keystroke
The bottom-push rule anchored on #root :has(> [data-slot=conversation]): a :has() match cache invalidates on every #root subtree mutation, and the streaming chat mutates that subtree constantly — every layout-push frame (the bottom drag writes --dsh-sidebar-height per frame) paid a whole-document selector re-evaluation. The shell's locator (which already holds the measured column node for the ResizeObserver) now tags it with [data-dsh-center-col]; the rule evaluates once per node. The tag retargets with the ref on boot swap / HMR node replacement and is removed on unmount; the drag lane pins exactly-one-tagged-node and its identity as the conversation slot wrapper's parent. The dead [data-pane] branch (renamed away in alpha hosts) is gone. TextEditor: the CodeMirror updateListener re-stringified the WHOLE document into React state on every keystroke (O(docLength) per key, re-rendering the shell per key). The listener now only flips the dirty flag; the preview's draft is snapshotted from the live view when entering preview (or on a content swap), and save() already reads the live document.
This commit is contained in:
@@ -752,7 +752,7 @@ ctx.effect(() =>
|
||||
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。
|
||||
- **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。
|
||||
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。
|
||||
- **布局变量**(`<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。宿主推挤 = `#root` margin/width calc + centerCol `margin-bottom`;锚点用**复合选择器双保险**(禁止 `nth-child`):`#root [data-dsh-frame] > [data-pane="conversation"]` 与 `#root :has(> [data-slot="conversation"])`(两代命名同元素,`drag-layout.e2e.ts` 断言)。
|
||||
- **布局变量**(`<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。宿主推挤 = `#root` margin/width calc + centerCol `margin-bottom`;锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为 `[data-slot="conversation"]` 的父级)。
|
||||
- **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数):
|
||||
- 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。
|
||||
- **strip 取值链**:⓪ `web` 方案强制 0;① `navigator.windowControlsOverlay` 真实几何(`wco.ts` 订阅 `geometrychange`,**为 0 也权威**,`visible=false` 幽灵 API 视为缺失);② URL inset;③ 壳预设 `stripFor`(仅 `preset`);④ 手动 `titleBarStripPx`(仅 `custom`);⑤ 0。驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`。
|
||||
|
||||
@@ -756,6 +756,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
?.parentElement as HTMLElement | undefined
|
||||
if (col === undefined || !col.isConnected) {
|
||||
if (centerColRef.current !== null) {
|
||||
centerColRef.current.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = null
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
@@ -768,8 +769,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// measure it once. Same-node size changes are the ResizeObserver's
|
||||
// job — no forced measurement here, because a forced
|
||||
// getBoundingClientRect per mutation would reflow the shell at
|
||||
// mutation cadence.
|
||||
// mutation cadence. The tag retargets with the ref: layout.css's
|
||||
// bottom-push rule anchors on [data-dsh-center-col], so exactly the
|
||||
// measured node carries it (a stale tag on a swapped-out node would
|
||||
// leave the push rule anchorless or doubled).
|
||||
centerColRef.current?.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = col
|
||||
col.setAttribute('data-dsh-center-col', '')
|
||||
observer?.disconnect()
|
||||
observer = new ResizeObserver(measureCenter)
|
||||
observer.observe(col)
|
||||
@@ -822,6 +828,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
observer?.disconnect()
|
||||
watcher.disconnect()
|
||||
htmlStyleWatcher.disconnect()
|
||||
centerColRef.current?.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = null
|
||||
}
|
||||
// Opening the bottom panel re-runs the whole locate/measure chain: a
|
||||
|
||||
@@ -114,11 +114,13 @@ export function TextEditor(props: FileViewerProps) {
|
||||
}, [content])
|
||||
|
||||
// Create the CodeMirror editor once the content is loaded. The view owns
|
||||
// the document; React only tracks dirty/draft state through the update
|
||||
// listener. For markdown the view stays mounted while previewing (hidden),
|
||||
// so unsaved edits survive the preview/edit toggle. The theme + syntax
|
||||
// colors live in a compartment so a scheme flip reconfigures only that
|
||||
// part — the document, undo history and scroll position survive.
|
||||
// the document; React only tracks dirty state through the update listener
|
||||
// (the draft — the preview's text — is snapshotted from the live view on
|
||||
// entering preview, not re-stringified per keystroke). For markdown the
|
||||
// view stays mounted while previewing (hidden), so unsaved edits survive
|
||||
// the preview/edit toggle. The theme + syntax colors live in a compartment
|
||||
// so a scheme flip reconfigures only that part — the document, undo
|
||||
// history and scroll position survive.
|
||||
useEffect(() => {
|
||||
if (content === undefined) return
|
||||
const host = hostRef.current
|
||||
@@ -139,7 +141,6 @@ export function TextEditor(props: FileViewerProps) {
|
||||
...(language !== null ? [language] : []),
|
||||
CodeMirrorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
setDraft(update.state.doc.toString())
|
||||
setDirty(true)
|
||||
}
|
||||
}),
|
||||
@@ -226,6 +227,17 @@ export function TextEditor(props: FileViewerProps) {
|
||||
if (mode === 'edit') viewRef.current?.requestMeasure()
|
||||
}, [mode])
|
||||
|
||||
// Snapshot the live document into the draft whenever the preview needs
|
||||
// it: entering preview (the markdown preview renders `draft ?? content`)
|
||||
// and a content swap (the view was just re-created above, so the read is
|
||||
// the new document — matching the reset-to-null of a clean tab). The
|
||||
// updateListener used to re-stringify the WHOLE document on every
|
||||
// keystroke (O(docLength) per key) for a draft only preview reads.
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
setDraft(view === null ? null : view.state.doc.toString())
|
||||
}, [mode, content])
|
||||
|
||||
const save = (): void => {
|
||||
const view = viewRef.current
|
||||
if (view === null || savingRef.current) return
|
||||
|
||||
+12
-12
@@ -35,14 +35,16 @@
|
||||
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* The AppFrame's center column, anchored by data attributes (see above).
|
||||
Composite selector: DSH 0.1.x versions name the center grid item
|
||||
`[data-pane="conversation"]`, while rc.8-era shells put a
|
||||
`[data-slot="conversation"]` child inside it — both selectors resolve to
|
||||
the SAME element on live pages (verified), and keeping both future-proofs
|
||||
the rule against a host rename without touching shell-specific markup. */
|
||||
#root [data-dsh-frame] > [data-pane="conversation"],
|
||||
#root :has(> [data-slot="conversation"]) {
|
||||
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
|
||||
locate/measureCenter) tags the measured column node with
|
||||
[data-dsh-center-col] — one attribute write per node lifetime. The rule
|
||||
used to anchor on a structural has()-selector over the conversation slot
|
||||
wrapper, whose match cache invalidates on every #root subtree mutation
|
||||
(the streaming chat mutates it constantly), re-evaluating the selector
|
||||
against the whole document; the tag is evaluated once per node instead.
|
||||
A stretched grid item shrinks by its margins, so the conversation
|
||||
content (output + input bar) lifts without touching the sidebars. */
|
||||
#root [data-dsh-center-col] {
|
||||
margin-bottom: var(--dsh-sidebar-height, 0px);
|
||||
/* Grid/flex items default to min-height:auto. A long unbreakable token
|
||||
(OAuth URL) then grows this column past the viewport and clips the
|
||||
@@ -68,8 +70,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root,
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"],
|
||||
body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -94,8 +95,7 @@ body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root,
|
||||
#root [data-dsh-frame] > [data-pane="conversation"],
|
||||
#root :has(> [data-slot="conversation"]) {
|
||||
#root [data-dsh-center-col] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,35 +612,28 @@ test('bottom panel never flashes full-width after a width drag release (issue #2
|
||||
}
|
||||
})
|
||||
|
||||
test('the bottom-push anchor resolves through the composite selectors (at least one; same element when both)', async ({ page }) => {
|
||||
// layout.css pushes the bottom panel via the center column. The selector
|
||||
// is COMPOSITE on purpose: `[data-pane="conversation"]` (0.1.x naming)
|
||||
// and `:has(> [data-slot="conversation"])` (rc.8-era naming) — HOST
|
||||
// VERSIONS MAY RENAME THE ATTRIBUTE (issue #208 comment / PR #226), so
|
||||
// the contract is "at least one resolves", and when both resolve they
|
||||
// must hit the SAME element (otherwise the push would land twice).
|
||||
test('the bottom-push anchor resolves through the shell-written center-column tag', async ({ page }) => {
|
||||
// layout.css pushes the bottom panel via the center column, anchored on
|
||||
// the [data-dsh-center-col] tag the Sidebar shell's locator writes onto
|
||||
// the measured column node (Sidebar.tsx locate — the slot wrapper
|
||||
// [data-slot="conversation"]'s parent). The tag must sit on exactly ONE
|
||||
// node: a stale tag on a swapped-out node (boot swap / HMR) would leave
|
||||
// the push rule anchorless or doubled.
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => document.querySelectorAll('#root [data-dsh-center-col]').length), { timeout: 90_000 })
|
||||
.toBe(1)
|
||||
const anchors = await page.evaluate(() => {
|
||||
const a = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]')
|
||||
const b = document.querySelector('#root :has(> [data-slot="conversation"])')
|
||||
const frame = document.querySelector('#root [data-dsh-frame]')
|
||||
const tagged = document.querySelector('#root [data-dsh-center-col]')
|
||||
const slotParent = document.querySelector('#root [data-slot="conversation"]')?.parentElement ?? null
|
||||
return {
|
||||
a: a !== null,
|
||||
b: b !== null,
|
||||
same: a !== null && b !== null && a === b,
|
||||
frameChildren: frame !== null
|
||||
? [...frame.children].map(el => `${el.tagName}[${[...el.attributes].map(attr => attr.name).filter(name => name.startsWith('data-')).join(',')}]`)
|
||||
: [],
|
||||
tagged: tagged !== null,
|
||||
same: tagged !== null && slotParent !== null && tagged === slotParent,
|
||||
}
|
||||
})
|
||||
expect(
|
||||
anchors.a || anchors.b,
|
||||
`at least one bottom-push anchor must resolve on this host (frame children: ${anchors.frameChildren.join(' / ') || 'no [data-dsh-frame]'})`,
|
||||
).toBe(true)
|
||||
if (anchors.a && anchors.b) {
|
||||
expect(anchors.same, 'both selectors must hit the SAME center-column element').toBe(true)
|
||||
}
|
||||
expect(anchors.tagged, 'the tagged center column must resolve').toBe(true)
|
||||
expect(anchors.same, 'the tag must sit on the conversation slot wrapper\'s parent (the measured column)').toBe(true)
|
||||
})
|
||||
|
||||
/* ── bottom-strip drag with the right panel CLOSED must not touch the host
|
||||
|
||||
@@ -9,9 +9,13 @@ import { describe, expect, it } from 'vitest'
|
||||
const css = readFileSync('src/client/layout.css', 'utf8')
|
||||
|
||||
describe('layout.css conversation column', () => {
|
||||
it('targets the AppFrame center column', () => {
|
||||
expect(css).toContain('[data-pane="conversation"]')
|
||||
expect(css).toContain('[data-slot="conversation"]')
|
||||
it('targets the AppFrame center column through the shell-written tag', () => {
|
||||
// The bottom-push rule anchors on the [data-dsh-center-col] tag the
|
||||
// Sidebar shell's locator writes onto the measured column node (see
|
||||
// Sidebar.tsx locate) — not on a `:has()` selector, whose cache
|
||||
// invalidates on every #root subtree mutation (the streaming chat).
|
||||
expect(css).toContain('[data-dsh-center-col]')
|
||||
expect(css).not.toContain(':has(')
|
||||
})
|
||||
|
||||
it('allows the center column to shrink and wrap long tokens', () => {
|
||||
@@ -21,7 +25,7 @@ describe('layout.css conversation column', () => {
|
||||
|
||||
it('leaves overflow ownership to the host conversation descendants', () => {
|
||||
const conversationRule = css.match(
|
||||
/#root \[data-dsh-frame\][\s\S]*?\{([\s\S]*?)\n\}/,
|
||||
/#root \[data-dsh-center-col\][\s\S]*?\{([\s\S]*?)\n\}/,
|
||||
)?.[1]
|
||||
expect(conversationRule).toBeDefined()
|
||||
expect(conversationRule).not.toMatch(/(?:^|[;\s])overflow\s*:/)
|
||||
|
||||
Reference in New Issue
Block a user