Merge origin/main (v0.22.1 与三个热修 PR) into feat/files-changes-uiux

冲突面 11 个文件,逐项裁决如下:

1. src/client/native/index.ts —— **取 main 版**。main 的 v0.22.1 在同一处做了更强的修复
   (清理循环同样跳过 FILES_KIND,另加 `disposeSafely` 与「注册失败必须回滚已占用 id /
   已建槽位」),是我们那行守卫的超集。
   ⚠️ 但 main 分支从来没有过我们 task-19 的 `records.attachStore(store)` 调用,整文件
   采用会**静默丢掉**它(records 拿不到 store → `toggleExpanded` 直接 return → 点目录
   树毫无反应),且 jsdom 单测看不见(它们自己绑 records)。已按原注释恢复该调用,并在
   注释里记下这次合并教训。真实宿主 lane 证明:恢复前 `zz-expand-refresh` 红(请求为
   空、`big` 不展开),恢复后 9/9 全绿。
2. package.json / dsh.plugin.json / src/client/service.ts —— 版本相关冲突取我们的
   0.24.1(支持线 ^0.2.0-rc.1 不变)。
3. CHANGELOG / README(中英各一)—— 双方版本节全部保留:我们的 v0.24.1 / v0.24.0 +
   **新增缺失的 v0.23.0 节**(开发线,内容随 v0.24.1 发布)+ main 的 v0.22.1。
   同时修正两处事实错误:① 「0.1.7 线请固定 v0.23.0」→ **v0.22.1**(0.23.0 从未发布);
   ② 安装对照表里 0.1.7 行的安装命令同改。README 并补上 v0.23.0 摘要(含 ⚠️ 删除工作区
   路径检测这一安全相关的行为变更)。
4. docs/external-plugin-guide.md —— 版本行取我们的(0.2.0 线)。

验证(合并后的树):pnpm typecheck / lint / test = **137 文件 / 1491 通过 / 9 跳过**
(含 main 新增的 native-registration.spec.ts);pnpm build ✓;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.2.0-rc.1 dsh' pnpm test:mount = **9/9**
(mount 5 + perf 2 + main 的 native-reload 1 + 我们的 zz-expand-refresh 1)。
This commit is contained in:
Menghuan1918
2026-09-28 23:13:36 +08:00
13 changed files with 799 additions and 86 deletions
+77
View File
@@ -517,6 +517,28 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
const modal = page.locator('[data-mermaid-modal]')
await pane.locator('[data-mermaid-diagram] svg').first().click()
await expect(modal, 'clicking the diagram must open the zoom modal').toHaveCount(1, { timeout: 10_000 })
// While it is up, the modal is the plugin's OTHER viewport-sized BODY CHILD:
// without the app-region reset the shell's blanket `body > :not(#root)`
// no-drag makes it cancel every window-drag strip (and the macOS
// double-click-title zoom) until it closes — issue #772. Measured against the
// SHELL's own darwin rules (present in this page's stylesheet, activated by
// the platform attribute) rather than a copy: the modal must compute the
// neutral `none`, while its toolbar buttons keep `no-drag` so a click on them
// is never a window drag.
const modalRegions = await page.evaluate(() => {
const previous = document.documentElement.dataset.platform
document.documentElement.dataset.platform = 'darwin'
const modalElement = document.querySelector('[data-mermaid-modal]')
const region = (element: Element | null): string => element === null
? 'missing'
: getComputedStyle(element).getPropertyValue('-webkit-app-region').trim()
const result = { modal: region(modalElement), button: region(modalElement?.querySelector('button') ?? null) }
if (previous === undefined) delete document.documentElement.dataset.platform
else document.documentElement.dataset.platform = previous
return result
})
expect(modalRegions.modal, 'the zoom modal must opt out of app-region computation').toBe('none')
expect(modalRegions.button, 'its buttons must stay no-drag').toBe('no-drag')
await page.keyboard.press('Escape')
await expect(modal, 'Esc must close the zoom modal').toHaveCount(0, { timeout: 10_000 })
await assertNoCrash()
@@ -615,6 +637,61 @@ test('conservative auto: URL stamps alone never modify the layout; plugin chrome
return false
})
expect(hasNoDragRule, 'the bundle must ship the drag-region opt-out rule').toBe(true)
// The plugin host is a direct body child, so on macOS it picks up the
// shell's blanket `body > :not(#root) { no-drag }` and — because app-region
// ignores pointer-events — the viewport-sized layer inside it cancelled every
// window-drag strip beneath it (issue #772). A text match only proves the
// declaration shipped, so this probe reproduces the SHELL's two darwin rules
// verbatim, flips the platform attribute the shell stamps, and reads the
// COMPUTED property. Two things to know when reading the expectations below:
// the strip itself computes `drag` in every configuration (a fix validated
// against the strip alone proves nothing), and `[data-dsh-panel-host]` is NOT
// a body child (it lives inside the host), so its own value is pinned by the
// unit guard rather than by this probe — see the `layer` expectation.
const regions = await page.evaluate(() => {
const style = document.createElement('style')
style.textContent = [
'html[data-platform=darwin] [data-window-drag]{-webkit-app-region:drag}',
'html[data-platform=darwin] body>:not(#root){-webkit-app-region:no-drag}',
].join('\n')
const strip = document.createElement('div')
strip.setAttribute('data-window-drag', '')
strip.style.cssText = 'position:fixed;top:0;left:0;right:0;height:8px'
const probe = document.createElement('div')
document.head.appendChild(style)
// INSIDE #root: the shell's real drag strip is a frame child, not a body
// child — a body child would be caught by the blanket rule itself (it is
// id-bearing, so it outranks the `[data-window-drag]` rule).
const root = document.querySelector('#root')
;(root ?? document.body).appendChild(strip)
const host = document.querySelector('[data-dsh-better-sidebar]')
const layer = document.querySelector('[data-dsh-panel-host]')
// This stamp-only page has no session, so the panel host has no child of
// its own; `probe` stands in for the workbench panel the same `> *`
// selector targets in a real session.
layer?.appendChild(probe)
const previous = document.documentElement.dataset.platform
document.documentElement.dataset.platform = 'darwin'
const read = (element: Element | null): string => element === null
? 'missing'
: getComputedStyle(element).getPropertyValue('-webkit-app-region').trim()
const result = { strip: read(strip), host: read(host), layer: read(layer), layerChild: read(probe) }
probe.remove()
strip.remove()
style.remove()
if (previous === undefined) delete document.documentElement.dataset.platform
else document.documentElement.dataset.platform = previous
return result
})
expect(regions.strip, 'the drag strip itself must keep computing `drag`').toBe('drag')
expect(regions.host, 'the plugin host must not take the shell blanket no-drag').toBe('none')
// NOT discriminating here, and kept as a regression nail only: the layer is
// not a body child, and this engine does not propagate the property into it,
// so it reads `none` pre- and post-fix. Its rule is pinned by
// tests/panel-host-css.spec.ts; in Electron (#772's CDP reading) the layer
// DOES end up no-drag through that propagation.
expect(regions.layer, 'the panel layer must keep its own reset').toBe('none')
expect(regions.layerChild, 'panels stay no-drag so their controls keep receiving clicks').toBe('no-drag')
})
test('standard WCO geometry drives the strip reactively (issue #257)', async ({ page }) => {
+160
View File
@@ -0,0 +1,160 @@
/**
* In-page CLIENT ENTRY REPLACEMENT ("plugin reload") — the deployment-level
* guard for the `dsh-better-sidebar:files` orphaned-tab-type bug.
*
* Field symptom (issues #770/#771, reproduced on the official desktop shell):
* a client-side replacement of the plugin entry — a plugin-market update, a
* disable→enable in the Plugins page, an HMR rebundle — left the host's tab
* type id `dsh-better-sidebar:files` registered with nobody holding its
* disposer, so the plugin could never register it again: every later `sync()`
* reported `native register files error: … already registered` and the Files
* takeover rendered the host's "nothing here can view this kind of content"
* face until a page refresh.
*
* Root cause (fixed in v0.22.1): `sync()`'s drop loop treated the plugin's own
* `files` takeover like a service descriptor, so every notification tore it
* down and re-created it in the same pass. During the reload the re-creation
* ran on the dying plugin context: `tabs.register` (host context, still alive)
* took the id and `ctx.slots.inject` (plugin context) then threw
* `cannot create effect on inactive context`, losing the disposer.
*
* The trigger here is the same one the host's own HMR uses: the installed
* client bundle is stat-polled, and a changed revision (`mtime`/`ctime`/`size`)
* makes the host tear the entry's fiber down and re-import it IN PAGE — no
* full page navigation, which is what made the old failure permanent.
*/
import { mkdirSync, utimesSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, type APIRequestContext } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc, sendFirstMessage } from './host'
/** Workspace the sidebar renders against (created by this lane's seeding). */
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace')
/** A file seeded into the workspace, opened through the Files takeover. */
const SEEDED_FILE = 'hello.txt'
/**
* The installed client bundle the host's HMR stat-poll watches. Derived from
* the profile the server was booted with (`DSH_HOME`), overridable for a
* hand-run lane.
*/
const CLIENT_JS = process.env.DSH_E2E_PLUGIN_CLIENT_JS
?? (process.env.DSH_HOME === undefined
? undefined
: join(process.env.DSH_HOME, 'profiles/web/node_modules/dsh-better-sidebar/lib/client.js'))
/** The strip `fail()` pins to <body>; the plugin's registration failures land here. */
const REGISTRATION_ERROR = /native register .* error/
let api: APIRequestContext
test.beforeAll(async () => {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the reload lane\n')
api = await createHostApi()
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(
api, 'workspace.create', { path: WORKSPACE_PATH },
)
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
})
test.afterAll(async () => {
await api?.dispose()
})
test('the native tab types survive an in-page client entry replacement', async ({ page }) => {
// A hard failure, not a skip: this gate's whole value is that it cannot
// pass quietly when the harness stops telling us where the bundle lives.
if (CLIENT_JS === undefined) {
throw new Error('DSH_HOME (or DSH_E2E_PLUGIN_CLIENT_JS) must point at the profile running this server')
}
const consoleErrors: string[] = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
// A keyless boot stacks onboarding takeovers over the shell (versioned
// welcome notice, then the provider-config dialog). Same bounded dismissal
// loop as the mount lane: a masked click is retried, an absent takeover is
// fine.
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e] no onboarding takeover appeared; proceeding without dismissal')
}
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try {
await button.click({ timeout: 4_000 })
dismissed = true
await page.waitForTimeout(1_000)
} catch {
// Masked by the takeover stacked above it; the next round tries again.
}
}
if (!dismissed) break
}
// The session header (and with it the native right sidebar's controls) only
// exists once the session has a turn — the mount lane does the same.
await sendFirstMessage(page)
await page.locator('[data-sidebar-right-expand]').first().click()
const pane = page.locator('[data-sidebar-right-panel]')
await expect(page.locator('[data-sidebar-right-guide]')).toBeVisible({ timeout: 30_000 })
// The takeover is ALIVE before the reload: its guide row opens the plugin's
// explorer and the seeded file is listed.
await page.locator('[data-sidebar-right-guide-entry="files"]').click()
await expect(pane.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)).toHaveCount(1, { timeout: 30_000 })
// ── Trigger: bump the installed bundle's revision ─────────────────────
// Tag the CURRENT host element so the replacement is observable: a disposal
// removes it (`host.remove()`), and the new activation appends a fresh one.
await page.evaluate(() => {
document.querySelector('[data-dsh-better-sidebar]')?.setAttribute('data-e2e-generation', 'before-reload')
})
const now = new Date()
utimesSync(CLIENT_JS!, now, new Date(now.getTime() + 2_000))
await expect(
page.locator('[data-dsh-better-sidebar][data-e2e-generation="before-reload"]'),
'the host must replace the plugin entry in page (new activation)',
).toHaveCount(0, { timeout: 60_000 })
// Let the fresh activation finish its registrations.
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 60_000 })
await page.waitForTimeout(2_000)
// ── Assertions: no registration failure, takeover usable ──────────────
const registrationErrors = consoleErrors.filter(message => REGISTRATION_ERROR.test(message))
expect(registrationErrors, registrationErrors.join('\n')).toEqual([])
await expect(
page.locator('body > div', { hasText: 'native register files error' }),
'the failure strip must not be pinned',
).toHaveCount(0)
// The Files takeover works again after the replacement: the guide row is
// offered and the explorer still lists the seeded file.
if (await page.locator('[data-sidebar-right-guide]').count() === 0) {
await page.locator('[data-dockkit-add-tab]').first().click()
}
const filesEntry = page.locator('[data-sidebar-right-guide-entry="files"]')
await expect(filesEntry, 'the Files guide row must be offered again').toHaveCount(1, { timeout: 30_000 })
await filesEntry.click()
// The explorer must really render again — assert the CONTENT first (a bare
// "no unavailable marker" check passes on an empty pane), then the host's
// "a kind with no registrant" fallback must be absent.
await expect(pane.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)).toHaveCount(1, { timeout: 30_000 })
await expect(
pane.locator('[data-sidebar-right-unavailable]'),
'the host must not fall back to its "nothing can view this kind" face',
).toHaveCount(0)
})