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:
@@ -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 }) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The native tab registration LIFECYCLE (`src/client/native/index.ts`): what a
|
||||
* store/service notification may do to the host's tab-type registry, and what
|
||||
* a failed registration is allowed to leave behind.
|
||||
*
|
||||
* The failure this locks is a plugin RELOAD: cordis tears the fiber down, each
|
||||
* built-in descriptor is unregistered one by one, every unregistration notifies
|
||||
* the plugin's service, and `sync()` therefore runs again on a context that is
|
||||
* already inactive. A registration attempted there throws
|
||||
* (`ctx.effect` on an inactive context) — and if the host-side type was already
|
||||
* registered at that point, its disposer is lost, the id stays taken for the
|
||||
* rest of the page's life, and every later attempt is refused with
|
||||
* `tab type id "…" is already registered`. The kind then renders the host's
|
||||
* "nothing here can view this kind of content" face until a page refresh.
|
||||
*
|
||||
* Three properties keep that impossible, and all are asserted here:
|
||||
* 1. an unrelated notification never re-creates the `files` takeover (the
|
||||
* tear-down/re-register window the reload used to walk into);
|
||||
* 2. a registration that fails after its type was registered releases the
|
||||
* type again, so the id stays registrable and one broken descriptor cannot
|
||||
* take the others down with it;
|
||||
* 3. slots installed before the failing one are released too (the partial
|
||||
* release inside `registerSlots`).
|
||||
*
|
||||
* The assertions are written against the fake registry's EVENT LOG, not only
|
||||
* against the set of live ids: an orphaned type leaves the key set identical to
|
||||
* a healthy one, so a key-set assertion would pass on the unfixed code and pin
|
||||
* nothing.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { registerNativeSurface } from '../src/client/native/index.ts'
|
||||
import { createNativeTabRecords } from '../src/client/native/tab-adapter.tsx'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
/**
|
||||
* The fake `sidebarRightTabs`, mirroring the host's two hard rules: one
|
||||
* registration per id, and the returned disposer is the only way to free it.
|
||||
*/
|
||||
function createRegistry() {
|
||||
const registered = new Map<string, () => void>()
|
||||
const events: string[] = []
|
||||
let failReleaseOf: string | undefined
|
||||
return {
|
||||
registered,
|
||||
events,
|
||||
failRelease(id: string): void { failReleaseOf = id },
|
||||
register(definition: { id: string }): () => void {
|
||||
if (registered.has(definition.id)) {
|
||||
throw new Error(`sidebarRight: tab type id "${definition.id}" is already registered`)
|
||||
}
|
||||
events.push(`register ${definition.id}`)
|
||||
// The host's disposer is IDEMPOTENT (a cordis effect disposer returns
|
||||
// the same task on a second call), so a repeated release must neither
|
||||
// log again nor pretend to free something twice.
|
||||
let released = false
|
||||
const dispose = (): void => {
|
||||
if (released) return
|
||||
if (failReleaseOf === definition.id) throw new Error(`cannot release ${definition.id}`)
|
||||
released = true
|
||||
registered.delete(definition.id)
|
||||
events.push(`release ${definition.id}`)
|
||||
}
|
||||
registered.set(definition.id, dispose)
|
||||
return dispose
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake client context: `slots.inject` runs its callback synchronously (the
|
||||
* real one resolves the declaration and calls `ctx.effect`, which throws while
|
||||
* `inactive`), a slot listed in `failSlots` throws from `slots.register`, and
|
||||
* `inject` hands back the registry while honouring the callback's own teardown.
|
||||
*
|
||||
* Slot failures are keyed by `name::key` — NOT by `key` alone. Both slots of a
|
||||
* descriptor share the same key, so a key-keyed failure can only ever fail the
|
||||
* FIRST slot; failing the second one for a single descriptor is the only shape
|
||||
* that exercises `registerSlots`' partial release (the first slot is already
|
||||
* installed when the second throws).
|
||||
*/
|
||||
function createHost() {
|
||||
const registry = createRegistry()
|
||||
/** Slot registrations and releases, in order (the partial-release witness). */
|
||||
const slotEvents: string[] = []
|
||||
// One mutable state object the ctx closures read: the test flips it after
|
||||
// the host was built.
|
||||
const state = {
|
||||
/** True while the plugin's context is being torn down (the reload case). */
|
||||
inactive: false,
|
||||
/** `name::key` slots whose registration throws. */
|
||||
failSlots: new Set<string>(),
|
||||
}
|
||||
const slotId = (options: { name: string; key?: string }): string => `${options.name}::${options.key ?? ''}`
|
||||
const ctx = {
|
||||
inject: (
|
||||
_deps: readonly string[],
|
||||
callback: (injected: { get: (name: string) => unknown }) => (() => void) | void,
|
||||
) => {
|
||||
const teardown = callback({ get: () => registry })
|
||||
return { dispose: () => { if (typeof teardown === 'function') teardown() } }
|
||||
},
|
||||
get: () => registry,
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => Array<() => void> | (() => void)) => {
|
||||
if (state.inactive) throw new Error('cannot create effect on inactive context')
|
||||
return typeof callback === 'function' ? callback() : () => {}
|
||||
},
|
||||
register: (options: { name: string; key?: string }) => {
|
||||
const id = slotId(options)
|
||||
if (state.failSlots.has(id)) throw new Error(`slot registration for "${id}" failed`)
|
||||
slotEvents.push(`register ${id}`)
|
||||
return () => { slotEvents.push(`release ${id}`) }
|
||||
},
|
||||
},
|
||||
}
|
||||
return { registry, slotEvents, state, ctx }
|
||||
}
|
||||
|
||||
/** A store + service with two enabled descriptors, plus the record registry. */
|
||||
function createPlugins() {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Files', component: () => null, icon: () => null })
|
||||
service.registerTab({ id: 'git', title: 'Changes', component: () => null })
|
||||
return { store, service, records: createNativeTabRecords() }
|
||||
}
|
||||
|
||||
/** Silence (and count) the failure logs the fixed code reports on purpose. */
|
||||
function silenceFailures(): { restore: () => void; messages: string[] } {
|
||||
const messages: string[] = []
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||
messages.push(args.map(String).join(' '))
|
||||
})
|
||||
return { restore: () => { spy.mockRestore() }, messages }
|
||||
}
|
||||
|
||||
/**
|
||||
* The phase labels a surface reports through the client's visible diagnostic
|
||||
* channel (`reportFailure` → `fail('native ' + phase, error)`).
|
||||
*/
|
||||
function collectReports(): { phases: string[]; report: (phase: string) => void } {
|
||||
const phases: string[] = []
|
||||
return { phases, report: (phase: string) => { phases.push(phase) } }
|
||||
}
|
||||
|
||||
describe('native tab registration lifecycle', () => {
|
||||
it('releases a type whose slot registration failed, and can register it again later', () => {
|
||||
const host = createHost()
|
||||
const { store, service, records } = createPlugins()
|
||||
const reports = collectReports()
|
||||
// The reload: the context goes inactive while notifications are still
|
||||
// arriving, so every slot registration fails after its type registered.
|
||||
host.state.inactive = true
|
||||
expect(() => registerNativeSurface({
|
||||
ctx: host.ctx as never, store, service, records, reportFailure: reports.report,
|
||||
})).not.toThrow()
|
||||
expect([...host.registry.registered.keys()], 'a failed registration leaves no type behind').toEqual([])
|
||||
// Each of them is reported through the visible channel, not swallowed.
|
||||
expect(reports.phases.sort()).toEqual(['register editor', 'register files', 'register git'])
|
||||
|
||||
// The context comes back (the reload's next activation): the same ids must
|
||||
// be registrable — no "already registered" from the orphan. The event log
|
||||
// is the discriminating assertion: a leaked type would keep the key set
|
||||
// looking correct while the retry throws into the same catch.
|
||||
host.state.inactive = false
|
||||
host.registry.events.length = 0
|
||||
store.setPrefs(store.getPrefs())
|
||||
expect(host.registry.events.sort()).toEqual([
|
||||
'register dsh-better-sidebar:editor',
|
||||
'register dsh-better-sidebar:files',
|
||||
'register dsh-better-sidebar:git',
|
||||
])
|
||||
})
|
||||
|
||||
it('never re-creates the "files" takeover on an unrelated notification', () => {
|
||||
const host = createHost()
|
||||
const { store, service, records } = createPlugins()
|
||||
const dispose = registerNativeSurface({ ctx: host.ctx as never, store, service, records })
|
||||
// The takeover is this module's own entry (never a descriptor), so no
|
||||
// store/service notification has any business touching it. Before the fix
|
||||
// every notification disposed and re-created it.
|
||||
host.registry.events.length = 0
|
||||
for (let round = 0; round < 3; round++) store.setPrefs(store.getPrefs())
|
||||
expect(host.registry.events, 'no notification may tear the "files" takeover down').toEqual([])
|
||||
expect(host.registry.registered.has('dsh-better-sidebar:files')).toBe(true)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('keeps the other tab types when one descriptor cannot register', () => {
|
||||
const host = createHost()
|
||||
const { store, service, records } = createPlugins()
|
||||
const reports = collectReports()
|
||||
// The SECOND slot of ONE descriptor: its type and its body slot are
|
||||
// already installed when this one throws.
|
||||
host.state.failSlots.add('sidebar.right.pane.tab.title::dsh-better-sidebar:git')
|
||||
const log = silenceFailures()
|
||||
try {
|
||||
expect(() => registerNativeSurface({
|
||||
ctx: host.ctx as never, store, service, records, reportFailure: reports.report,
|
||||
})).not.toThrow()
|
||||
expect([...host.registry.registered.keys()].sort()).toEqual([
|
||||
'dsh-better-sidebar:editor',
|
||||
'dsh-better-sidebar:files',
|
||||
])
|
||||
expect(reports.phases).toEqual(['register git'])
|
||||
// The body slot installed before the failure was released; the failing
|
||||
// one never registered, so there is nothing to release for it.
|
||||
expect(host.slotEvents.filter(event => event === 'release sidebar.right.pane.tab::dsh-better-sidebar:git'))
|
||||
.toHaveLength(1)
|
||||
expect(host.slotEvents.filter(event => event === 'release sidebar.right.pane.tab.title::dsh-better-sidebar:git'))
|
||||
.toEqual([])
|
||||
|
||||
// The broken descriptor is retried on the next sync instead of being
|
||||
// written off — and the retry REGISTERS it (event log), rather than
|
||||
// merely ending up with a key set a leaked type produces as well.
|
||||
host.state.failSlots.clear()
|
||||
host.registry.events.length = 0
|
||||
store.setPrefs(store.getPrefs())
|
||||
expect(host.registry.events).toEqual(['register dsh-better-sidebar:git'])
|
||||
} finally {
|
||||
log.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('releases every registration on teardown even when one release throws', () => {
|
||||
const host = createHost()
|
||||
const { store, service, records } = createPlugins()
|
||||
const log = silenceFailures()
|
||||
try {
|
||||
const dispose = registerNativeSurface({ ctx: host.ctx as never, store, service, records })
|
||||
host.registry.failRelease('dsh-better-sidebar:editor')
|
||||
expect(() => dispose()).not.toThrow()
|
||||
expect([...host.registry.registered.keys()]).toEqual(['dsh-better-sidebar:editor'])
|
||||
} finally {
|
||||
log.restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -44,4 +44,41 @@ describe('panel host layer css', () => {
|
||||
expect(degraded, 'the degraded-mode rule must exist').toBeDefined()
|
||||
expect(degraded).not.toMatch(/overflow\s*:/)
|
||||
})
|
||||
|
||||
it('keeps the app-region opt-out that un-breaks macOS window drag (issue #772)', () => {
|
||||
// The shell's `html[data-platform=darwin] body > :not(#root) { no-drag }`
|
||||
// carries an id in its specificity, so only `initial !important` wins;
|
||||
// `initial` computes to the NEUTRAL `none` (does not subtract from the
|
||||
// drag region) while `no-drag` is the subtracting value — and the literal
|
||||
// keyword `none` is NOT neutral (it computes to `no-drag`), which is
|
||||
// exactly why this guard pins `initial`. Hence the split: decorative
|
||||
// viewport-sized layers opt out, their panels stay no-drag so controls
|
||||
// keep receiving clicks. Dropping the `!important` silently re-breaks
|
||||
// window drag / double-click-title zoom on macOS (there is no macOS
|
||||
// runner to catch it), so pin the shape here where Linux `pnpm test`
|
||||
// fails instead.
|
||||
const text = stripComments(css)
|
||||
expect(text, 'the panel host must opt out of app-region computation').toMatch(
|
||||
/:global\(\[data-dsh-panel-host\]\)\s*\{[^}]*-webkit-app-region:\s*initial\s*!important/,
|
||||
)
|
||||
expect(text, 'panels must stay no-drag').toMatch(
|
||||
/:global\(\[data-dsh-panel-host\]\)\s*>\s*\*\s*\{[^}]*-webkit-app-region:\s*no-drag/,
|
||||
)
|
||||
expect(text, 'the mermaid zoom modal is the other persistent viewport-sized body child').toMatch(
|
||||
/\.mermaidModal\s*\{[^}]*-webkit-app-region:\s*initial\s*!important/,
|
||||
)
|
||||
// The interactive popups must NOT get `initial`: they are buttons/handles
|
||||
// the shell already keeps no-drag, and outranking that rule makes a press
|
||||
// start a window drag (the #103/#111 click-swallow class). Checked per
|
||||
// DECLARATION BLOCK, not per single-selector rule: a selector list
|
||||
// (`.selectionPopup, .other { … initial !important }`) would slip past a
|
||||
// simple "class immediately followed by {" pattern.
|
||||
const resets = [...text.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
|
||||
.filter(([, , body]) => /-webkit-app-region:\s*initial/.test(body ?? ''))
|
||||
expect(resets.length, 'the file must declare the app-region opt-out').toBeGreaterThan(0)
|
||||
const resetSelectors = resets.map(([, selector]) => selector ?? '').join(',')
|
||||
for (const excluded of ['selectionPopup']) {
|
||||
expect(resetSelectors, `${excluded} must not opt out of app-region`).not.toContain(excluded)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user