mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge remote-tracking branch 'origin/master' into windows-sandbox-acl-diagnosis
# Conflicts: # packages/sandbox/sandbox-windows-acl/package.json
This commit is contained in:
commit
17877121ba
70 files changed
+627
-71965
No files matched your search
@@ -61,7 +61,7 @@
|
||||
"electron-builder": "^26.15.3",
|
||||
"extract-zip": "^2.0.1",
|
||||
"js-yaml": "^4.2.0",
|
||||
"koffi": "^3.1.0",
|
||||
"koffi": "3.1.1",
|
||||
"pnpm": "11.7.0",
|
||||
"sharp": "^0.35.3",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -70,6 +70,10 @@ async function copyPdfText(page: Page, preview: Locator, expected: string): Prom
|
||||
const text = preview.locator('[data-pdf-text] span:not(.markedContent)').filter({ hasText: expected }).first()
|
||||
await text.waitFor({ state: 'visible' })
|
||||
await expect.poll(() => text.evaluate(node => getComputedStyle(node).userSelect)).toBe('text')
|
||||
await expect.poll(() => text.evaluate(node => ({
|
||||
background: getComputedStyle(node, '::selection').backgroundColor,
|
||||
color: getComputedStyle(node, '::selection').color,
|
||||
}))).toEqual({ background: 'color(srgb 0.231373 0.509804 0.964706 / 0.4)', color: 'rgba(0, 0, 0, 0)' })
|
||||
await text.click({ clickCount: 3 })
|
||||
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString().trim())).toBe(expected)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
|
||||
@@ -938,6 +942,7 @@ else process.exit(1);
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
|
||||
await expectPdfPageSpacing(preview)
|
||||
await copyPdfText(page, preview, 'Selectable PDF text')
|
||||
await successShot(page, `pdf-spacing-${colorScheme}`)
|
||||
}
|
||||
expect(await body.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true)
|
||||
@@ -1033,14 +1038,16 @@ else process.exit(1);
|
||||
expect(forward).toContain('THREE TASKS')
|
||||
expect(forward).not.toContain('REFLECTION')
|
||||
expect(forward).not.toContain('AFTER TABLE')
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString())).toBe(forward)
|
||||
const backward = await drag(priority, start)
|
||||
expect(backward).toContain('THREE TASKS')
|
||||
expect(backward).not.toContain('REFLECTION')
|
||||
expect(backward).not.toContain('AFTER TABLE')
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString())).toBe(backward)
|
||||
expect(await selectionLayer.locator('br').first().evaluate(node => getComputedStyle(node, '::selection').backgroundColor))
|
||||
.toBe('rgba(0, 0, 0, 0)')
|
||||
await successShot(page, 'pdf-drag-selection')
|
||||
sections.push('## PDF drag selection\n\n- Table selection: forward and backward drags exclude later sections\n- Line-break highlight: transparent')
|
||||
sections.push('## PDF drag selection\n\n- Table selection: forward and backward drags exclude later sections\n- Line-break highlight: transparent\n- Text selection: translucent blue in light and dark themes; canvas text remains visible\n- Mouse release preserves the selected text')
|
||||
|
||||
const pngResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/workspaceFiles/readBytes'
|
||||
&& (response.request().postDataJSON() as { payload: { args: { path: string } } }).payload.args.path === 'tiny.png')
|
||||
@@ -1584,6 +1591,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
await expect.poll(() => preview.locator('[data-pdf-preview]').evaluate(node => getComputedStyle(node).backgroundColor))
|
||||
.toBe(colorScheme === 'dark' ? 'rgb(21, 21, 23)' : 'rgb(235, 238, 242)')
|
||||
await expectPdfPageSpacing(preview)
|
||||
await copyPdfText(page, preview, '中文文档')
|
||||
await successShot(page, `office-background-${colorScheme}`)
|
||||
}
|
||||
await (await revealDocumentZoom(page, preview)).click()
|
||||
@@ -1695,6 +1703,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/office-font-notice.md', import.meta.url)), [
|
||||
'# Office font warning', '',
|
||||
'- Document preparation: centered 28px spinner with visible rendering status in both themes',
|
||||
'- Text selection: translucent blue with transparent overlay text in both themes; Chinese text copies unchanged',
|
||||
'- Document backdrop: cool light grey in light mode; matte black in dark mode',
|
||||
'- Word and PowerPoint paper layout: 12px page gaps and outer backdrop insets',
|
||||
'- Warning precedes reload in the same toolbar: true',
|
||||
@@ -1719,6 +1728,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
await officeZoom.click()
|
||||
await page.getByRole('menuitem', { name: '150%', exact: true }).click()
|
||||
await expectPdfResolution(canvas)
|
||||
if (extension === 'pptx') await copyPdfText(page, preview, '中文文档')
|
||||
if (['doc', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0)
|
||||
if (extension === 'pptx') {
|
||||
await preview.locator('[data-document-zoom-scrollport]').evaluate((node) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Office font warning
|
||||
|
||||
- Document preparation: centered 28px spinner with visible rendering status in both themes
|
||||
- Text selection: translucent blue with transparent overlay text in both themes; Chinese text copies unchanged
|
||||
- Document backdrop: cool light grey in light mode; matte black in dark mode
|
||||
- Word and PowerPoint paper layout: 12px page gaps and outer backdrop insets
|
||||
- Warning precedes reload in the same toolbar: true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- heading "插件" [level=1]
|
||||
- paragraph: 添加和管理插件
|
||||
- text: 安装、启用和配置插件
|
||||
- button "插件说明"
|
||||
- button "刷新"
|
||||
- button "添加插件"
|
||||
- heading "官方" [level=3]
|
||||
|
||||
@@ -7,4 +7,5 @@
|
||||
- text: slow-package
|
||||
- button "插件安装引导和示例"
|
||||
- button "安装源 默认安装源"
|
||||
- note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。
|
||||
- button "安装"
|
||||
@@ -5,4 +5,5 @@
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- button "插件安装引导和示例"
|
||||
- button "安装源 中国大陆镜像源"
|
||||
- note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。
|
||||
- button "安装" [disabled]
|
||||
@@ -1,5 +1,6 @@
|
||||
- heading "插件" [level=1]
|
||||
- paragraph: 添加和管理插件
|
||||
- text: 安装、启用和配置插件
|
||||
- button "插件说明"
|
||||
- button "刷新"
|
||||
- button "添加插件"
|
||||
- heading "官方" [level=3]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- heading "插件" [level=1]
|
||||
- paragraph: 添加和管理插件
|
||||
- text: 安装、启用和配置插件
|
||||
- button "插件说明"
|
||||
- button "刷新" [disabled]
|
||||
- button "添加插件" [disabled]
|
||||
- status "正在读取插件…"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
- heading "插件" [level=1]
|
||||
- paragraph: 添加和管理插件
|
||||
- text: 安装、启用和配置插件
|
||||
- button "插件说明"
|
||||
- button "刷新"
|
||||
- button "添加插件"
|
||||
- heading "官方" [level=3]
|
||||
|
||||
@@ -123,9 +123,10 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(await skeleton.getAttribute('role')).toBe('status')
|
||||
expect(await skeleton.getAttribute('aria-label')).toBe('正在读取插件…')
|
||||
expect(await panel.getAttribute('aria-busy')).toBe('true')
|
||||
const actions = panel.locator(':scope > header button')
|
||||
const actions = panel.locator(':scope > header > div:last-child button')
|
||||
expect(await actions.count()).toBe(2)
|
||||
for (const action of await actions.all()) expect(await action.isDisabled()).toBe(true)
|
||||
expect(await panel.getByRole('button', { name: '插件说明' }).isEnabled()).toBe(true)
|
||||
const loadingAria = await captureStableAria(probe, '[data-plugin-panel]', scaffold.workspaceCwd)
|
||||
if (aria === '') aria = loadingAria
|
||||
else expect(loadingAria).toBe(aria)
|
||||
@@ -371,6 +372,28 @@ describe('web e2e: plugin manager', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list'))
|
||||
const panel = await openPluginsPanel()
|
||||
|
||||
const info = panel.getByRole('button', { name: '插件说明' })
|
||||
await info.hover()
|
||||
const hoverHelp = page.getByRole('tooltip')
|
||||
await hoverHelp.waitFor()
|
||||
expect(await hoverHelp.textContent()).toBe('在这里配置官方插件,安装和管理其他插件。内置插件列表及运行状态可在「设置 → 内置插件」中查看')
|
||||
await panel.getByRole('heading', { name: '插件', exact: true }).hover()
|
||||
await hoverHelp.waitFor({ state: 'hidden' })
|
||||
await info.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
const help = page.getByRole('tooltip')
|
||||
await help.waitFor()
|
||||
expect(await help.textContent()).toContain('设置 → 内置插件')
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await help.count()).toBe(0)
|
||||
expect(await info.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await info.click()
|
||||
await panel.getByRole('heading', { name: '插件', exact: true }).hover()
|
||||
await help.hover()
|
||||
expect(await help.isVisible()).toBe(true)
|
||||
await panel.getByRole('heading', { name: '插件', exact: true }).click()
|
||||
expect(await help.count()).toBe(0)
|
||||
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 })
|
||||
const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('false')
|
||||
@@ -760,6 +783,20 @@ describe('web e2e: plugin manager', () => {
|
||||
const field = dialog.getByRole('textbox', { name: '包名或地址' })
|
||||
const install = dialog.getByRole('button', { name: '安装', exact: true })
|
||||
expect(await install.isDisabled()).toBe(true)
|
||||
expect(await dialog.getByRole('note').textContent()).toContain('请确认插件来源可信')
|
||||
await dialog.getByRole('button', { name: '插件安装引导和示例' }).click()
|
||||
for (const [example, hint] of [
|
||||
['https://github.com/author/dsh-plugin', '请替换为实际的 Git 仓库地址'],
|
||||
['/Users/name/my-plugin', '请替换为本机插件目录的实际路径'],
|
||||
]) {
|
||||
await dialog.getByRole('button', { name: `填入示例 ${example}` }).click()
|
||||
expect(await field.inputValue()).toBe(example)
|
||||
expect(await dialog.getByRole('status').textContent()).toBe(hint)
|
||||
}
|
||||
await field.fill('/actual/plugin-directory')
|
||||
expect(await dialog.getByRole('status').count()).toBe(0)
|
||||
await dialog.getByRole('button', { name: '收起引导' }).click()
|
||||
expect(await dialog.getByRole('note').textContent()).toContain('请确认插件来源可信')
|
||||
// A name the list already shows is refused without asking the Host.
|
||||
await field.fill('@fixture/bundle')
|
||||
await install.click()
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 5fca19ac2bb1ddac
|
||||
zh: 00d78fe7e80ba4f7
|
||||
/cookbook-reviewing-persistence-type-changes/optional-inspect-the-change:
|
||||
en: 6e2c03bf51b3c400
|
||||
zh: 2545a193c89bbf1c
|
||||
en: 3f85edb803afe870
|
||||
zh: 545c651b9e5ccb56
|
||||
/cookbook-reviewing-persistence-type-changes/1-record-the-change:
|
||||
en: 342bd49412db070e
|
||||
zh: a8d14958e9a637d9
|
||||
|
||||
@@ -41,6 +41,8 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte
|
||||
|
||||
Record the commits supplying those files with the report. Add `--json` for structured output. This read-only comparison groups shared changes with their affected roots and uses actual literal `kind`/`form` values instead of union positions. Ambiguous alternatives remain separate additions and removals. Its compatibility section copies every root's authoritative classifier result; the structural explanation does not replace acknowledgement checks. Current catalog labels and declaration names are descriptive metadata; structural anchors and fingerprints identify types.
|
||||
|
||||
The current machine inventory stores complete graphs in `roots`. A `types` entry contains `digest`, `names`, and `sources`, plus an explicit `schema` when roots cannot reconstruct that graph exactly. Readers resolve omitted graphs from root subgraphs by digest and validate explicit graphs directly, preserving every type and its metadata. Historical full entries remain readable. `formatVersion` identifies normalization rules; storage compaction does not change root fingerprints or require an acknowledgement.
|
||||
|
||||
<a id="acknowledge"></a>
|
||||
## 1. Record the change
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte
|
||||
|
||||
在报告旁记录这些文件对应的 commit。添加 `--json` 可获取结构化输出。此只读比较将共享变更与受影响的根类型归组,使用实际字面量 `kind`/`form` 值代替联合类型位置。无法唯一匹配的候选项保留为独立的新增与删除。兼容性部分复制每个根类型的权威分类结果;结构说明不替代确认检查。当前目录标签和声明名称是描述元数据;结构锚点和指纹标识类型。
|
||||
|
||||
当前机器清单在 `roots` 中保存完整图。每个 `types` 条目包含 `digest`、`names` 和 `sources`;若根无法精确重建该图,条目还会保存显式 `schema`。读取器按摘要从根的子图恢复省略的图,并直接验证显式图,保留每个类型及其元数据。历史完整条目仍然可读。`formatVersion` 标识规范化规则;存储压缩不改变根指纹,也不需要确认记录。
|
||||
|
||||
<a id="acknowledge"></a>
|
||||
## 1. 记录变更
|
||||
|
||||
|
||||
Generated
-71780
File diff suppressed because it is too large.
Load diff
@@ -50,7 +50,7 @@ export async function defaultWorkspaceDirectory(
|
||||
case 'darwin':
|
||||
({ stdout } = await run('osascript', [
|
||||
'-e', 'POSIX path of (path to documents folder from user domain without folder creation)',
|
||||
], signal))
|
||||
], signal, 'hidden'))
|
||||
break
|
||||
case 'win32':
|
||||
({ stdout } = await run('powershell.exe', [
|
||||
@@ -58,10 +58,10 @@ export async function defaultWorkspaceDirectory(
|
||||
'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); '
|
||||
+ '[Environment]::GetFolderPath([Environment+SpecialFolder]::MyDocuments, '
|
||||
+ '[Environment+SpecialFolderOption]::DoNotVerify)',
|
||||
], signal))
|
||||
], signal, 'hidden'))
|
||||
break
|
||||
case 'linux':
|
||||
({ stdout } = await run('xdg-user-dir', ['DOCUMENTS'], signal))
|
||||
({ stdout } = await run('xdg-user-dir', ['DOCUMENTS'], signal, 'hidden'))
|
||||
break
|
||||
default:
|
||||
throw new Error(`system Documents directory is unavailable on ${platform}`)
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('system Documents directory', () => {
|
||||
const run = vi.fn<NativeCommandRunner>(async () => ({ stdout, stderr: '' }))
|
||||
const signal = new AbortController().signal
|
||||
await expect(defaultWorkspaceDirectory(undefined, signal, { platform, run })).resolves.toBe(path)
|
||||
expect(run).toHaveBeenCalledWith(command, expect.any(Array), signal)
|
||||
expect(run).toHaveBeenCalledWith(command, expect.any(Array), signal, 'hidden')
|
||||
})
|
||||
|
||||
it('uses the configured directory without a system lookup', async () => {
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
|
||||
en: 555eccf8bfdd5029
|
||||
zh: e0886cbf6e62bfe3
|
||||
en: b825d4197803d434
|
||||
zh: 30705edfc28495f7
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
|
||||
en: 915fdd040b4d211c
|
||||
zh: d77e0846f9cbc334
|
||||
en: 20e7310ab6867389
|
||||
zh: eed2957381abc0bd
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle:
|
||||
en: abc90531efa889d5
|
||||
zh: 0562570f85e69c35
|
||||
|
||||
@@ -27,7 +27,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Experimental** for experimental features — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section.
|
||||
Select **Plugins** in the sidebar. The information button beside the subtitle explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Experimental** for experimental features — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
|
||||
|
||||
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the image declared by their own `package.json.icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information.
|
||||
|
||||
@@ -41,7 +41,7 @@ On first use, when pnpm uses the unconfigured official npm registry and npmmirro
|
||||
|
||||
The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved.
|
||||
|
||||
**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.
|
||||
**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. Git and local-path examples require the actual address or path; a hint appears while either example remains in the field. A trust warning stays above **Install**, including when the guide is collapsed. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.
|
||||
|
||||
During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于实验性功能的带**实验性**标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。
|
||||
在侧栏选择**插件**。副标题旁的信息按钮说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于实验性功能的带**实验性**标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
|
||||
|
||||
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示各自 `package.json.icon` 声明的图片;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。
|
||||
|
||||
@@ -41,7 +41,7 @@ kind: "package-reference"
|
||||
|
||||
Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。
|
||||
|
||||
**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。
|
||||
**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。Git 地址和本地路径示例需要替换为实际值;输入框中保留示例时会显示提醒。可信来源提示始终显示在**安装**按钮上方,引导收起后也可见。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。
|
||||
|
||||
准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。
|
||||
|
||||
|
||||
@@ -47,12 +47,23 @@
|
||||
}
|
||||
|
||||
.pageIntro {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.infoButton {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -82,16 +93,6 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -474,6 +475,21 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.installFooter {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.templateHint {
|
||||
margin: -4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Where pnpm installs: the profile directory the first run names. */
|
||||
.installLocation {
|
||||
margin: 0;
|
||||
@@ -561,26 +577,27 @@
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The security warning that closes the guide: plugins run with the person's own permissions. */
|
||||
.guideSafety {
|
||||
/* Trust guidance stays beside the install action, including while examples are collapsed. */
|
||||
.installSafety {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary));
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.guideSafety > svg {
|
||||
.installSafety > svg {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
.guideList {
|
||||
@@ -599,6 +616,10 @@
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.guideItem > button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.guideIndex {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
@@ -637,7 +658,7 @@
|
||||
}
|
||||
|
||||
.guideExample code {
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-family: var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.guideExampleLabel {
|
||||
@@ -1016,7 +1037,7 @@ label.registryOption {
|
||||
}
|
||||
|
||||
.resultWarn {
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
/* The approval a blocked run asks for: the packages whose install scripts pnpm did not run, and the one action. */
|
||||
@@ -1025,9 +1046,9 @@ label.registryOption {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 0.5px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 40%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 40%, transparent);
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 8%, transparent);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.approvalTitle {
|
||||
@@ -1048,8 +1069,8 @@ label.registryOption {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary));
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.approvalList {
|
||||
@@ -1066,7 +1087,7 @@ label.registryOption {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { PluginInstallFailureKind, Registry } from '@deepseek-ai/dsh-api-re
|
||||
import {
|
||||
Button, IconCheckCircleFillRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineMedium,
|
||||
IconChevronRightOutlineRegular, IconCloseOutlineMedium,
|
||||
IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular,
|
||||
IconInfoOutlineRegular, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular,
|
||||
IconWarningOutlineRegular, Input, Modal, pointerModality,
|
||||
PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal,
|
||||
StateDot, Switch, Tag, TerminalBlock, Toast, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { createNavigationStore } from './navigation-store.ts'
|
||||
import { rowConfigKey, type OfficialItem } from './config-ledger.ts'
|
||||
import type { PluginManagerLocaleKey } from './locales.ts'
|
||||
import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, type PluginManagerLocaleKey } from './locales.ts'
|
||||
import {
|
||||
asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey,
|
||||
type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
|
||||
@@ -798,6 +798,7 @@ function InstallDialog({
|
||||
readonly onUseGithubMirror: () => void
|
||||
}): ReactNode {
|
||||
const errorId = useId()
|
||||
const templateHintId = useId()
|
||||
const guideId = useId()
|
||||
const approvalId = useId()
|
||||
const registryId = useId()
|
||||
@@ -875,6 +876,9 @@ function InstallDialog({
|
||||
: inputProblem.problem === 'network' && askedByCheck.length > 1
|
||||
? t('installProblemNetworkAll', { registries: registryList(askedByCheck, t, resolved) })
|
||||
: t(INPUT_PROBLEM_KEYS[inputProblem.problem], { reason: inputProblem.reason })
|
||||
const templateHint = install.spec === INSTALL_GIT_EXAMPLE
|
||||
? t('installGitTemplateHint')
|
||||
: install.spec === INSTALL_PATH_EXAMPLE ? t('installPathTemplateHint') : null
|
||||
return (
|
||||
<Modal
|
||||
open={install.open}
|
||||
@@ -885,10 +889,16 @@ function InstallDialog({
|
||||
className={css.installDialog as string}
|
||||
contentClassName={css.installContent as string}
|
||||
footer={(
|
||||
<Button variant="primary" className={css.wide} disabled={checking || empty} aria-busy={checking} onClick={onRun}>
|
||||
{checking ? <StateDot state="ongoing" /> : null}
|
||||
{t(checking ? 'installChecking' : 'installRun')}
|
||||
</Button>
|
||||
<div className={css.installFooter}>
|
||||
<p className={css.installSafety} role="note">
|
||||
<IconWarningOutlineRegular size={14} aria-hidden="true" />
|
||||
<span>{t('installGuideSafety')}</span>
|
||||
</p>
|
||||
<Button variant="primary" className={css.wide} disabled={checking || empty} aria-busy={checking} onClick={onRun}>
|
||||
{checking ? <StateDot state="ongoing" /> : null}
|
||||
{t(checking ? 'installChecking' : 'installRun')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className={css.installBody}>
|
||||
@@ -901,7 +911,7 @@ function InstallDialog({
|
||||
disabled={checking}
|
||||
aria-label={t(install.mirrorRecovery ? 'installPackageLabel' : 'installSpecLabel')}
|
||||
aria-invalid={install.inputError !== null}
|
||||
aria-describedby={install.inputError === null ? undefined : errorId}
|
||||
aria-describedby={inputSentence !== null ? errorId : templateHint !== null ? templateHintId : undefined}
|
||||
onChange={(event) => { onEditSpec(event.currentTarget.value) }}
|
||||
onCompositionStart={specComposition.onCompositionStart}
|
||||
onCompositionEnd={specComposition.onCompositionEnd}
|
||||
@@ -915,6 +925,9 @@ function InstallDialog({
|
||||
{inputSentence === null
|
||||
? null
|
||||
: <p id={errorId} className={css.inputError} role="alert">{inputSentence}</p>}
|
||||
{inputSentence === null && templateHint !== null
|
||||
? <p id={templateHintId} className={css.templateHint} role="status">{templateHint}</p>
|
||||
: null}
|
||||
<div className={css.optionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -969,10 +982,6 @@ function InstallDialog({
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className={css.guideSafety} role="note">
|
||||
<IconWarningOutlineRegular size={14} aria-hidden="true" />
|
||||
<span>{t('installGuideSafety')}</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
@@ -1324,7 +1333,14 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
<header className={css.pageHead} data-window-drag>
|
||||
<div>
|
||||
<h1 className={css.pageTitle}>{t('title')}</h1>
|
||||
<p className={css.pageIntro}>{t('intro')}</p>
|
||||
<div className={css.pageIntro}>
|
||||
<span>{t('intro')}</span>
|
||||
<Tooltip label={t('infoDescription')} side="bottom" delayMs={300} maxWidth={300} portal openOnClick>
|
||||
<Button variant="ghost" size="sm" className={css.infoButton} aria-label={t('infoLabel')}>
|
||||
<IconInfoOutlineRegular size={10} aria-hidden="true" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.toolbar}>
|
||||
<Tooltip label={t('refresh')} delayMs={500} focusDelayMs={500} side="bottom" portal disabled={!loaded || refreshing}>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/** Plugin management interface copy. */
|
||||
|
||||
/** Git template shared by the displayed example and replacement reminder. */
|
||||
export const INSTALL_GIT_EXAMPLE = 'https://github.com/author/dsh-plugin'
|
||||
/** Local-path template shared by the displayed example and replacement reminder. */
|
||||
export const INSTALL_PATH_EXAMPLE = '/Users/name/my-plugin'
|
||||
|
||||
/** Simplified Chinese dictionary and key source of truth. */
|
||||
export const zh = {
|
||||
panel: '插件',
|
||||
title: '插件',
|
||||
intro: '添加和管理插件',
|
||||
intro: '安装、启用和配置插件',
|
||||
infoLabel: '插件说明',
|
||||
infoDescription: '在这里配置官方插件,安装和管理其他插件。内置插件列表及运行状态可在「设置 → 内置插件」中查看',
|
||||
loading: '正在读取插件…',
|
||||
error: '可能由于网络问题,无法读取全部插件',
|
||||
unavailable: '本部署没有可管理的 profile,无法安装或启停插件。',
|
||||
@@ -56,12 +63,14 @@ export const zh = {
|
||||
installGuideIdExample: 'dsh-plugin-whale-pet',
|
||||
installGuideIdHint: '插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。',
|
||||
installGuideGitTitle: 'GitHub 仓库地址',
|
||||
installGuideGitExample: 'https://github.com/author/dsh-plugin',
|
||||
installGuideGitExample: INSTALL_GIT_EXAMPLE,
|
||||
installGuideGitHint: '插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。',
|
||||
installGuidePathTitle: '本地插件目录',
|
||||
installGuidePathExample: '/Users/name/my-plugin',
|
||||
installGuidePathExample: INSTALL_PATH_EXAMPLE,
|
||||
installGuidePathHint: '本机上插件目录的绝对路径,适用于自行开发或已下载的插件。',
|
||||
installGuideExampleLabel: '示例:',
|
||||
installGitTemplateHint: '请替换为实际的 Git 仓库地址',
|
||||
installPathTemplateHint: '请替换为本机插件目录的实际路径',
|
||||
installGuideFill: '填入示例',
|
||||
installGuideFillAria: '填入示例 {example}',
|
||||
installGuideSafety: '请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。',
|
||||
@@ -197,7 +206,9 @@ export type PluginManagerLocaleKey = keyof typeof zh
|
||||
export const en = {
|
||||
panel: 'Plugins',
|
||||
title: 'Plugins',
|
||||
intro: 'Add and manage plugins',
|
||||
intro: 'Install, enable, and configure plugins',
|
||||
infoLabel: 'About plugins',
|
||||
infoDescription: 'Configure official plugins and install or manage other plugins here. View the built-in plugin list and runtime status in Settings → Built-in plugins.',
|
||||
loading: 'Reading plugins…',
|
||||
error: 'Could not read all plugins, possibly due to a network problem',
|
||||
unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.',
|
||||
@@ -249,12 +260,14 @@ export const en = {
|
||||
installGuideIdExample: 'dsh-plugin-whale-pet',
|
||||
installGuideIdHint: 'The plugin package name is the npm package name (like dsh-xxx or @author/plugin): the part after dsh plugin add or pnpm add in a community plugin\'s README install command.',
|
||||
installGuideGitTitle: 'GitHub repository address',
|
||||
installGuideGitExample: 'https://github.com/author/dsh-plugin',
|
||||
installGuideGitExample: INSTALL_GIT_EXAMPLE,
|
||||
installGuideGitHint: 'The address of the plugin\'s open-source repository on GitHub; other Git hosts work too.',
|
||||
installGuidePathTitle: 'Local plugin directory',
|
||||
installGuidePathExample: '/Users/name/my-plugin',
|
||||
installGuidePathExample: INSTALL_PATH_EXAMPLE,
|
||||
installGuidePathHint: 'The absolute path of a plugin directory on this machine, developed here or downloaded.',
|
||||
installGuideExampleLabel: 'Example: ',
|
||||
installGitTemplateHint: 'Replace this with the actual Git repository address.',
|
||||
installPathTemplateHint: 'Replace this with the actual path to your local plugin directory.',
|
||||
installGuideFill: 'Use example',
|
||||
installGuideFillAria: 'Use the example {example}',
|
||||
installGuideSafety: 'Install only plugins you trust: they run with your permissions and can damage DeepSeek Harness or leak your data.',
|
||||
|
||||
@@ -893,6 +893,23 @@ describe('PluginManagerPage', () => {
|
||||
expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith('dsh-plugin-whale-pet')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ example: en.installGuideGitExample, key: 'installGitTemplateHint' as const },
|
||||
{ example: en.installGuidePathExample, key: 'installPathTemplateHint' as const },
|
||||
])('asks for a real value when the field holds $example, including after a language change', ({ example, key }) => {
|
||||
const install = { ...IDLE_INSTALL, open: true, spec: example }
|
||||
const { set, setLanguage } = renderTab({ install })
|
||||
const hint = screen.getByRole('status')
|
||||
expect(hint.textContent).toBe(en[key])
|
||||
expect(screen.getByRole('textbox', { name: en.installSpecLabel }).getAttribute('aria-describedby')).toBe(hint.id)
|
||||
setLanguage(zh)
|
||||
expect(screen.getByRole('status').textContent).toBe(zh[key])
|
||||
expect(screen.getByRole('note').textContent).toBe(zh.installGuideSafety)
|
||||
set({ install: { ...install, spec: '@actual/plugin' } })
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(screen.getByRole('textbox', { name: zh.installSpecLabel }).getAttribute('aria-describedby')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a guide under the field and drops an example into it', () => {
|
||||
const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
|
||||
expect(screen.queryByText(en.installGuideIdHint)).toBeNull()
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 27c3b3715866df73
|
||||
zh: 65c07b00516149ad
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: 83052e14c3fb34cf
|
||||
zh: 0adb09885723296a
|
||||
en: f78bd63b167a7574
|
||||
zh: 472eb7791f080ac2
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
|
||||
en: d79e20306593c4bf
|
||||
zh: 5c0f1100e2cd6fcc
|
||||
|
||||
@@ -84,7 +84,7 @@ Writing your own component in your own package is fine when the need is genuinel
|
||||
|
||||
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. A `Tooltip` nested in the anchor hides that preview while its own hover or focus label is visible; releasing, disabling, or unmounting the nested tooltip restores a still-open preview. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. RereLine truncated
|
||||
|
||||
`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; fitting does not synchronously measure the bubble or trigger a React render.
|
||||
`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; fitting does not synchronously measure the bubble or trigger a React render. Opt-in `openOnClick` also lets an informational button pin the same bubble for reading and associates it with the anchor description. Another click, Escape, Tab, or an outside pointerdown closes it; ordinary action tooltips still dismiss on click.
|
||||
|
||||
### Rendering agent output
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ kind: "package-library"
|
||||
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。锚点中嵌套的 `Tooltip` 在显示悬停或焦点标签时隐藏该预览;嵌套提示释放、禁用或卸载后,仍处于打开状态的预览会恢复。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按Line truncated
|
||||
|
||||
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。
|
||||
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。 信息按钮可显式启用 `openOnClick`,点击后保持同一个气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮的 tooltip 仍在点击时关闭。
|
||||
|
||||
### 渲染 agent 输出
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
}
|
||||
|
||||
.bubble[data-portal] { z-index: 1100; }
|
||||
.bubble[data-pinned] { pointer-events: auto; }
|
||||
.label { min-width: 0; }
|
||||
|
||||
/* A 16px keycap centered beside a 20px label has 5px of vertical clearance. */
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/** Anchor-preserving tooltips; an optional body portal escapes clipping containers and stacking contexts that cap the bubble's z-index. */
|
||||
|
||||
import { cloneElement, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { cloneElement, createContext, useCallback, useContext, useEffect, useId, useRef, useState } from 'react'
|
||||
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ShortcutKeys } from './ShortcutKeys.tsx'
|
||||
import { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts'
|
||||
import css from './Tooltip.module.css'
|
||||
// Tooltips take the wide answer — any key returns to the keyboard. Focus rings read the
|
||||
// narrower `data-input-modality` attribute the same module publishes.
|
||||
@@ -20,6 +21,7 @@ export const TooltipSuppression = createContext<((suppressed: boolean) => void)
|
||||
|
||||
/** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */
|
||||
interface AnchorProps {
|
||||
'aria-describedby'?: string | undefined
|
||||
ref?: Ref<HTMLElement> | undefined
|
||||
onMouseEnter?: MouseEventHandler | undefined
|
||||
onMouseLeave?: MouseEventHandler | undefined
|
||||
@@ -49,13 +51,17 @@ type TooltipLabel = string | (() => string)
|
||||
* toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default
|
||||
* half-viewport cap would render a slab wider than the surface the anchor sits on.
|
||||
* @param props.openOnClick - clicking also pins the bubble for reading; another click, Escape,
|
||||
* Tab, or an outside pointerdown dismisses it. Defaults to false for ordinary action tooltips.
|
||||
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
||||
* @returns the cloned anchor plus a fixed-position bubble, optionally portaled to the body.
|
||||
* The bubble stays hidden until ResizeObserver supplies its size for viewport fitting; clicking the
|
||||
* anchor dismisses the bubble until the next trigger, and focus arriving after a pointer
|
||||
* anchor dismisses the bubble unless openOnClick is enabled, and focus arriving after a pointer
|
||||
* interaction (a closing menu refocusing its trigger) never raises it.
|
||||
*/
|
||||
export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, focusDelayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; focusDelayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
|
||||
export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, focusDelayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, openOnClick = false, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; focusDelayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; openOnClick?: boolean; children: ReactElement<AnchorProps> }) {
|
||||
const id = useId()
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const anchor = useRef<HTMLElement | null>(null)
|
||||
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
||||
// anchor in Tooltip never silently severs the owner's ref.
|
||||
@@ -138,13 +144,14 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
|
||||
showTimer.current = null
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (pinned && (disabled || !openOnClick)) setPinned(false)
|
||||
if (disabled) {
|
||||
cancelShow()
|
||||
triggers.current = { hover: false, focus: false }
|
||||
setPos(null)
|
||||
}
|
||||
return cancelShow
|
||||
}, [cancelShow, disabled])
|
||||
}, [cancelShow, disabled, openOnClick, pinned])
|
||||
|
||||
const show = () => {
|
||||
if (disabled) return
|
||||
@@ -170,21 +177,40 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
|
||||
show()
|
||||
}, delay)
|
||||
}
|
||||
const withdraw = () => {
|
||||
const withdraw = useCallback(() => {
|
||||
setPinned(false)
|
||||
setPos(null)
|
||||
announce(false)
|
||||
}
|
||||
}, [announce])
|
||||
const hide = () => {
|
||||
cancelShow()
|
||||
if (!triggers.current.hover && !triggers.current.focus) withdraw()
|
||||
if (!triggers.current.hover && !triggers.current.focus && !pinned) withdraw()
|
||||
}
|
||||
const dismiss = useCallback(() => {
|
||||
cancelShow()
|
||||
triggers.current = { hover: false, focus: false }
|
||||
withdraw()
|
||||
}, [cancelShow, withdraw])
|
||||
useDismissOnOutsidePointer(anchor, openOnClick && visible, dismiss, bubble)
|
||||
useEffect(() => {
|
||||
if (!openOnClick || !visible) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' && event.key !== 'Tab') return
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation() }
|
||||
dismiss()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
return () => { document.removeEventListener('keydown', onKeyDown, true) }
|
||||
}, [dismiss, openOnClick, visible])
|
||||
|
||||
const content = visible && !suppressed && (
|
||||
<span
|
||||
ref={bubble}
|
||||
id={openOnClick ? id : undefined}
|
||||
className={css.bubble}
|
||||
data-side={side}
|
||||
data-portal={portal || undefined}
|
||||
data-pinned={pinned || undefined}
|
||||
data-align={align}
|
||||
data-has-shortcut={shortcutKeys?.length ? true : undefined}
|
||||
style={{ left: pos.x, top: y, visibility: 'hidden', ...maxWidth === undefined ? {} : { maxWidth } }}
|
||||
@@ -200,12 +226,21 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
|
||||
<TooltipSuppression.Provider value={setSuppressed}>
|
||||
{cloneElement(children, {
|
||||
ref: mergedRef,
|
||||
'aria-describedby': openOnClick && visible
|
||||
? [children.props['aria-describedby'], id].filter(Boolean).join(' ')
|
||||
: children.props['aria-describedby'],
|
||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterDelay(delayMs) },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); withdraw() },
|
||||
// Activating the anchor dismisses the bubble: the action often changes
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); if (!pinned) withdraw() },
|
||||
// Activating an ordinary action dismisses the bubble: it often changes
|
||||
// what the anchor now does (pin → unpin), and the click leaves the
|
||||
// anchor focused, which would otherwise pin the relabelled bubble up.
|
||||
onClick: (e) => { children.props.onClick?.(e); triggers.current.focus = false; cancelShow(); withdraw() },
|
||||
onClick: (e) => {
|
||||
children.props.onClick?.(e)
|
||||
triggers.current.focus = false
|
||||
cancelShow()
|
||||
if (openOnClick && !disabled && !pinned) { setPinned(true); show() }
|
||||
else withdraw()
|
||||
},
|
||||
onFocus: (e) => {
|
||||
children.props.onFocus?.(e)
|
||||
if (pointerModality()) return
|
||||
|
||||
@@ -434,6 +434,103 @@ describe('Tooltip', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('pins an informational tooltip on click and preserves the anchor description', () => {
|
||||
const clicked = vi.fn()
|
||||
render(<>
|
||||
<p id="existing-description">Existing description</p>
|
||||
<Tooltip label="Plugin information" openOnClick portal>
|
||||
<button aria-describedby="existing-description" onClick={clicked}>anchor</button>
|
||||
</Tooltip>
|
||||
</>)
|
||||
const anchor = screen.getByText('anchor')
|
||||
fireEvent.pointerDown(anchor)
|
||||
fireEvent.focus(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.click(anchor)
|
||||
expect(clicked).toHaveBeenCalledOnce()
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.textContent).toBe('Plugin information')
|
||||
expect(anchor.getAttribute('aria-describedby')).toBe(`existing-description ${bubble.id}`)
|
||||
fireEvent.mouseLeave(anchor)
|
||||
fireEvent.blur(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBe(bubble)
|
||||
fireEvent.pointerDown(bubble)
|
||||
expect(screen.getByRole('tooltip')).toBe(bubble)
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
expect(anchor.getAttribute('aria-describedby')).toBe('existing-description')
|
||||
fireEvent.click(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
fireEvent.click(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows informational tooltips on hover and focus, and consumes Escape only while open', () => {
|
||||
const onKeyDown = vi.fn()
|
||||
render(<div onKeyDown={onKeyDown}>
|
||||
<Tooltip label="Plugin information" openOnClick><button>anchor</button></Tooltip>
|
||||
</div>)
|
||||
const anchor = screen.getByText('anchor')
|
||||
fireEvent.mouseEnter(anchor)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Plugin information')
|
||||
fireEvent.mouseLeave(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.keyDown(anchor, { key: 'Tab' })
|
||||
fireEvent.focus(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
fireEvent.keyDown(anchor, { key: 'a' })
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
onKeyDown.mockClear()
|
||||
expect(fireEvent.keyDown(anchor, { key: 'Escape' })).toBe(false)
|
||||
expect(onKeyDown).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.keyDown(anchor, { key: 'Escape' })
|
||||
expect(onKeyDown).toHaveBeenCalledOnce()
|
||||
fireEvent.click(anchor)
|
||||
expect(fireEvent.keyDown(anchor, { key: 'Tab' })).toBe(true)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('cancels a delayed hover when clicking to pin the information', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
render(<Tooltip label="Plugin information" openOnClick delayMs={300}><button>anchor</button></Tooltip>)
|
||||
const anchor = screen.getByText('anchor')
|
||||
fireEvent.mouseEnter(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.click(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
fireEvent.mouseLeave(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
fireEvent.keyDown(anchor, { key: 'Escape' })
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
} finally {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a click pin when disabled or when click opening is removed', () => {
|
||||
const view = render(<Tooltip label="Plugin information" openOnClick><button>anchor</button></Tooltip>)
|
||||
const anchor = screen.getByText('anchor')
|
||||
fireEvent.click(anchor)
|
||||
view.rerender(<Tooltip label="Plugin information" openOnClick disabled><button>anchor</button></Tooltip>)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.click(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
view.rerender(<Tooltip label="Plugin information" openOnClick><button>anchor</button></Tooltip>)
|
||||
fireEvent.click(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
view.rerender(<Tooltip label="Plugin information"><button>anchor</button></Tooltip>)
|
||||
fireEvent.mouseLeave(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
view.rerender(<Tooltip label="Plugin information" openOnClick><button>anchor</button></Tooltip>)
|
||||
fireEvent.click(anchor)
|
||||
view.unmount()
|
||||
expect(fireEvent.keyDown(document.body, { key: 'Escape' })).toBe(true)
|
||||
})
|
||||
|
||||
it('a click on the anchor dismisses the bubble even while the anchor stays focused', () => {
|
||||
render(
|
||||
<Tooltip label="Pin session">
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: fb79cb7d59bc01af
|
||||
zh: 5f6202d8b0b62d7d
|
||||
/deepseek-ai-dsh-client-ui-sidebar-browser/use-this-package/minimal-configuration:
|
||||
en: 32511e10645b4154
|
||||
zh: ce0e0efd60dda336
|
||||
en: abf28ea63777e2a8
|
||||
zh: 1b68bdf5729de71e
|
||||
/deepseek-ai-dsh-client-ui-sidebar-browser/understand-the-implementation:
|
||||
en: 42b15ac6321706aa
|
||||
zh: e15901d8462ec7c7
|
||||
|
||||
@@ -44,7 +44,7 @@ Client plugins can open a tab through `ctx.sidebarRight.openTab('browser', { par
|
||||
|
||||
The `browser.new` command opens a separate Browser page in the focused dock pane, replacing a guide and retaining existing content pages. From the conversation or a floating content page, it uses the active dock pane. Desktop defaults to Cmd+T on macOS and Ctrl+T on Windows; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves the command unbound. The guide button uses a blue globe and displays the effective shortcut inline without a duplicate tooltip.
|
||||
|
||||
The toolbar provides Back, Forward, Reload, Go, and Open in system browser. Web also offers a per-tab sandbox toggle; disabling it is temporary and displays a warning. Desktop shows the observed page title. After a restart, Browser shows the saved title and URL; Restore or Reload opens that address only when requested.
|
||||
The toolbar provides Back, Forward, Reload, Go, and Open in system browser. Web also offers a per-tab sandbox toggle; disabling it is temporary and displays an amber warning with neutral text. Desktop shows the observed page title. After a restart, Browser shows the saved title and URL; Restore or Reload opens that address only when requested.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Client 插件可以调用 `ctx.sidebarRight.openTab('browser', { params: { url }
|
||||
|
||||
命令 `browser.new` 在焦点停靠分栏打开独立浏览器页,替换开始页并保留已有内容页。从聊天区或浮动内容页触发时,使用活动停靠分栏。桌面默认键在 macOS 上为 Cmd+T,在 Windows 上为 Ctrl+T;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。开始页按钮使用蓝色地球图标,并在按钮内显示有效快捷键,不额外弹出重复提示。
|
||||
|
||||
工具栏提供后退、前进、刷新、前往和在系统浏览器中打开。Web 还提供逐 tab sandbox 开关;关闭它是临时选择,并会显示警告。Desktop 显示观察到的页面标题。重启后,Browser 展示保存的标题和 URL;只有点击恢复或刷新才打开该地址。
|
||||
工具栏提供后退、前进、刷新、前往和在系统浏览器中打开。Web 还提供逐 tab sandbox 开关;关闭它是临时选择,并会显示灰字、琥珀色底的警告。Desktop 显示观察到的页面标题。重启后,Browser 展示保存的标题和 URL;只有点击恢复或刷新才打开该地址。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -224,9 +224,9 @@
|
||||
.sandboxWarning {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 12px;
|
||||
color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary));
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 8%, transparent);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.limit {
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 949156aa02ec8b07
|
||||
zh: d3675754e2a37b5d
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads:
|
||||
en: 5d8bf2e43d9b6dd0
|
||||
zh: b0d2d1d5a6f520db
|
||||
en: 5cdbd4a218315e70
|
||||
zh: d5054f541e0e0275
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview:
|
||||
en: b99b0e0b8efc2853
|
||||
zh: 4327352d1334fc6b
|
||||
|
||||
@@ -63,7 +63,7 @@ PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` s
|
||||
|
||||
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a cool light-grey background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. After zoom settles or fit width changes, nearby PDF pages redraw at the resolved scale multiplied by the device pixel ratio. The previous bitmap and selectable text stay visible until the replacement bitmap is ready; offscreen pages refresh when they approach the viewport.
|
||||
|
||||
Initial reads and renderer preparation, including Office conversion, share a centered 28px ongoing `StateDot` with the visible localized status “Rendering document...”. The status exposes the same accessible name and respects reduced-motion preferences. Additional text pages keep a compact inline spinner while loaded content stays visible. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF and converted Word/PowerPoint pages form a vertical sequence with 12px page gaps and a 12px outer inset showing the theme-specific document background; fit width reserves this inset, and pages render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
|
||||
Initial reads and renderer preparation, including Office conversion, share a centered 28px ongoing `StateDot` with the visible localized status “Rendering document...”. The status exposes the same accessible name and respects reduced-motion preferences. Additional text pages keep a compact inline spinner while loaded content stays visible. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF and converted Word/PowerPoint pages form a vertical sequence with 12px page gaps and a 12px outer inset showing the theme-specific document background; fit width reserves this inset, and pages render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Selections use translucent blue in both themes while the canvas retains the document text; blank line breaks remain unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
|
||||
|
||||
<a id="excel-preview"></a>
|
||||
## Excel preview
|
||||
|
||||
@@ -63,7 +63,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
|
||||
|
||||
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用冷调浅灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。缩放稳定或适应宽度发生变化后,视口附近的 PDF 页面按最终缩放比例乘以设备像素比重绘。新位图完成前保留原有画面和可选文字层;屏幕外的页面在接近视口时更新。
|
||||
|
||||
首次读取及渲染器准备(包括 Office 转换)共用居中的 28px ongoing `StateDot`,下方显示本地化状态文字“文档渲染中...”。状态向辅助技术提供相同的名称,并遵循减少动态效果偏好。追加文本页时使用紧凑的行内转圈,同时保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 与转换后的 Word/PowerPoint 页面组成纵向序列,页间距与四周留白均为 12px,露出当前主题的文档背景色;适应宽度会预留此留白,页面在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 tertiary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
|
||||
首次读取及渲染器准备(包括 Office 转换)共用居中的 28px ongoing `StateDot`,下方显示本地化状态文字“文档渲染中...”。状态向辅助技术提供相同的名称,并遵循减少动态效果偏好。追加文本页时使用紧凑的行内转圈,同时保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 与转换后的 Word/PowerPoint 页面组成纵向序列,页间距与四周留白均为 12px,露出当前主题的文档背景色;适应宽度会预留此留白,页面在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。两种主题均使用半透明蓝色选区,画布保留文档文字,空白换行不高亮;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 tertiary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
|
||||
|
||||
<a id="excel-preview"></a>
|
||||
## Excel 预览
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
}
|
||||
|
||||
.text ::selection {
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
background: var(--dsw-alias-bg-document-selection);
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 8fbfdb050e880f9b
|
||||
zh: dd201f049036c5c4
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets:
|
||||
en: cb63405e3ab1f395
|
||||
zh: 2a40c303286c8ea0
|
||||
en: 3c3b40e770dec2b1
|
||||
zh: 654c80a5bf4d85fa
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/scrollbar-rebinding:
|
||||
en: bf76e7c34a331595
|
||||
zh: 5accd0260ef7c61e
|
||||
|
||||
@@ -55,7 +55,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou
|
||||
|
||||
`base.css` owns the shared radius scale and settings-card material aliases. The material aliases resolve on `body`, alongside the active palette. Follow [Web styling](../../../docs/web-styling.md#corner-radii-and-settings-cards) when choosing component radii.
|
||||
|
||||
`src/styles/` holds eight sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `focus.css`, `onboarding.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors.
|
||||
`src/styles/` holds eight sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `focus.css`, `onboarding.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `--dsw-alias-bg-document-selection` uses blue-500 at 40% opacity in both themes for selections over original document colors. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors.
|
||||
|
||||
[`focus.css`](src/styles/focus.css) provides a `:focus-visible` fallback that names the ring colour through `var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary))` and the standard width through `--dsw-focus-ring-width`, never the outline style — so a control that disables its outline stays paintless, and one that declares no ring keeps the standard geometry instead of Chromium's `auto 1px`. The theme resolves this blue to `#4176E6` in light mode and `#7AAAFF` in dark mode. Component outlines and focus-ring shadows use the same colour expression, including rings on descendants and pseudo-elements. `--dsw-focus-ring-width` (2px) is the standard width; dense tables and toolbars may keep 1px, and offsets remain component-owned.
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ kind: "package-reference"
|
||||
|
||||
`base.css` 持有共享圆角尺度与设置卡片材质别名。材质别名在 `body` 上随当前色板解析。组件圆角选择遵循 [Web 样式参考](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards)。
|
||||
|
||||
`src/styles/` 下有八张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`focus.css`、`onboarding.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。
|
||||
`src/styles/` 下有八张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`focus.css`、`onboarding.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`--dsw-alias-bg-document-selection` 在两种主题中均使用 40% 不透明度的 blue-500,供保留文档原色的选区使用。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。
|
||||
|
||||
[`focus.css`](src/styles/focus.css) 提供 `:focus-visible` 兜底:通过 `var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary))` 声明焦点环颜色,并通过 `--dsw-focus-ring-width` 声明标准宽度,但不声明轮廓样式——因此禁用轮廓的控件仍然不绘制,而没有自有焦点环的控件保持标准几何,而不是落到 Chromium 的 `auto 1px`。主题将该蓝色解析为浅色模式的 `#4176E6` 和深色模式的 `#7AAAFF`。组件轮廓与焦点环阴影使用同一颜色表达式,包括后代和伪元素上的环。`--dsw-focus-ring-width`(2px)是标准宽度;密集表格与工具栏可以保留 1px,offset 仍由组件决定。
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ body[data-ds-dark-theme] {
|
||||
body {
|
||||
--dsw-alias-bg-base: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-100);
|
||||
--dsw-alias-bg-document-selection: color-mix(in srgb, var(--dsw-static-blue-500) 40%, transparent);
|
||||
--dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-700);
|
||||
--dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-00);
|
||||
@@ -277,6 +278,7 @@ body {
|
||||
body[data-ds-dark-theme] {
|
||||
--dsw-alias-bg-base: var(--dsw-static-neutral-bluish-950);
|
||||
--dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-950);
|
||||
--dsw-alias-bg-document-selection: color-mix(in srgb, var(--dsw-static-blue-500) 40%, transparent);
|
||||
--dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-300);
|
||||
--dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-875);
|
||||
--dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-850);
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"koffi": "^3.1.0",
|
||||
"koffi": "3.1.1",
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:*",
|
||||
"@deepseek-ai/dsh-native-command": "workspace:*",
|
||||
"koffi": "^3.1.0"
|
||||
"koffi": "3.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function pickNativeDirectory(
|
||||
const result = await run('osascript', [
|
||||
'-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
|
||||
'-e', 'POSIX path of selectedFolder',
|
||||
], signal)
|
||||
], signal, 'hidden')
|
||||
return outputPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
if (!signal.aborted && errorCode(error) === 1
|
||||
@@ -80,7 +80,7 @@ export async function pickNativeDirectory(
|
||||
try {
|
||||
const result = await run('zenity', [
|
||||
'--file-selection', '--directory', '--title=Select Workspace Directory',
|
||||
], signal)
|
||||
], signal, 'hidden')
|
||||
return outputPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
rethrowIfAborted(signal, error)
|
||||
@@ -91,7 +91,7 @@ export async function pickNativeDirectory(
|
||||
try {
|
||||
const result = await run('kdialog', [
|
||||
'--getexistingdirectory', '.', '--title', 'Select Workspace Directory',
|
||||
], signal)
|
||||
], signal, 'hidden')
|
||||
return outputPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
rethrowIfAborted(signal, error)
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('native directory picker', () => {
|
||||
it('uses the macOS folder chooser and maps user cancellation to null', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' }))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBe('/Users/test/project/')
|
||||
expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal))
|
||||
expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal), 'hidden')
|
||||
|
||||
run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)'))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull()
|
||||
|
||||
@@ -168,7 +168,7 @@ export async function output(
|
||||
command: string, args: readonly string[], timeoutMs: number, internals: ResolvedInternals,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await internals.run(command, args, AbortSignal.timeout(timeoutMs))
|
||||
const { stdout } = await internals.run(command, args, AbortSignal.timeout(timeoutMs), 'hidden')
|
||||
return stdout
|
||||
} catch {
|
||||
// Swallows spawn, non-zero-exit, and timeout-abort failures alike: a
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-lazy-require": "workspace:*",
|
||||
"@deepseek-ai/dsh-win32-process": "workspace:*",
|
||||
"koffi": "^3.1.0",
|
||||
"koffi": "3.1.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@deepseek-ai/dsh-session-format-v3-to-v4": "workspace:*",
|
||||
"@deepseek-ai/node-addon-system": "workspace:~",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"koffi": "^3.1.0"
|
||||
"koffi": "3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-lazy-require": "workspace:*",
|
||||
"@deepseek-ai/dsh-win32-process": "workspace:*",
|
||||
"koffi": "^3.1.0",
|
||||
"koffi": "3.1.1",
|
||||
"node-pty": "1.2.0-beta.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-lazy-require": "workspace:*",
|
||||
"koffi": "^3.1.0"
|
||||
"koffi": "3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 5ebca99c0488e1c8
|
||||
zh: 24b2f8f341ff648d
|
||||
/deepseek-ai-dsh-native-command/summary:
|
||||
en: 18e2f1f3798079dd
|
||||
zh: 6f5579374e208624
|
||||
en: 8369dd3eb2af0781
|
||||
zh: fa9dfd927cc8aaad
|
||||
/deepseek-ai-dsh-native-command/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
@@ -18,8 +18,8 @@
|
||||
en: aba695cba233905f
|
||||
zh: c6359da30eb8e372
|
||||
/deepseek-ai-dsh-native-command/use-this-package/running-a-command:
|
||||
en: 3a499bda33e33102
|
||||
zh: d01abbf863810607
|
||||
en: 6dbc6c9bf6db34be
|
||||
zh: 9ab16a86ca5abcc2
|
||||
/deepseek-ai-dsh-native-command/use-this-package/injecting-the-command-boundary:
|
||||
en: 987bed629737b59d
|
||||
zh: 258e30f813b0edcb
|
||||
@@ -33,8 +33,8 @@
|
||||
en: a19bb4cb9f6de785
|
||||
zh: cc930d8facf1ca07
|
||||
/deepseek-ai-dsh-native-command/understand-the-implementation/what-execfile-gives-the-runner:
|
||||
en: b16da99b2ed73bfd
|
||||
zh: 5e806c31a6296366
|
||||
en: ce6180ce8209db94
|
||||
zh: 30000a1a073110e8
|
||||
/deepseek-ai-dsh-native-command/further-exploration:
|
||||
en: a3af562edb6e0385
|
||||
zh: e26e2c311c07d1c5
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`dsh-native-command` runs host executables without a shell and opens Host filesystem paths through the desktop. The command runner captures utf8 output, propagates cancellation, and hides transient Windows consoles. The path opener supports default-application and text-editor intents, browser-renderable documents, WSL translation, and desktop availability checks. It is a library, not a plugin: no `ctx`, no state, no events.
|
||||
`dsh-native-command` runs host executables without a shell and opens Host filesystem paths through the desktop. The command runner captures utf8 output, propagates cancellation, and requires explicit Windows startup visibility. The path opener supports default-application and text-editor intents, browser-renderable documents, WSL translation, and desktop availability checks. It is a library, not a plugin: no `ctx`, no state, no events.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -34,10 +34,10 @@ import { runNativeCommand } from '@deepseek-ai/dsh-native-command'
|
||||
|
||||
declare const script: string
|
||||
declare const signal: AbortSignal
|
||||
const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], signal)
|
||||
const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], signal, 'hidden')
|
||||
```
|
||||
|
||||
On exit 0 the call resolves with captured stdout and stderr. On any failure it rejects with the exit `code` and both captured streams attached, so a caller can tell a missing tool (`ENOENT`), a cancellation (`ABORT_ERR`), and a real command failure apart without re-running the command.
|
||||
On exit 0 the call resolves with captured stdout and stderr. On any failure it rejects with the exit `code` and both captured streams attached, so a caller can tell a missing tool (`ENOENT`), a cancellation (`ABORT_ERR`), and a real command failure apart without re-running the command. The required fourth argument, `window: 'hidden' | 'visible'`, controls Windows startup visibility and has no effect on other platforms. Background commands pass `'hidden'`; GUI launchers pass `'visible'`. The path opener selects `'visible'` for Explorer, including WSL handoffs and file selection, because hiding Explorer also hides its folder window.
|
||||
|
||||
### Injecting the command boundary
|
||||
|
||||
@@ -72,7 +72,7 @@ The command runner is a thin wrapper over Node's `execFile`. The path opener sel
|
||||
|
||||
### What execFile gives the runner
|
||||
|
||||
`execFile` spawns the executable directly with an argv array — no shell string, no shell interpretation of the arguments. The `signal` option terminates the child when the caller's abort fires; `windowsHide` suppresses the transient console window on Windows. On a non-zero exit or spawn error, the callback attaches `code`, `stdout`, and `stderr` to the rejected error and keeps the original error as `cause`.
|
||||
`execFile` spawns the executable directly with an argv array — no shell string, no shell interpretation of the arguments. The `signal` option terminates the child when the caller's abort fires; the runner maps `window` to Node's `windowsHide` option. On a non-zero exit or spawn error, the callback attaches `code`, `stdout`, and `stderr` to the rejected error and keeps the original error as `cause`.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-library"
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-native-command` 无需 shell 即可运行 Host 可执行文件,并通过桌面打开 Host 文件系统路径。命令运行器捕获 utf8 输出、传播取消,并隐藏 Windows 瞬时控制台。路径打开器支持默认应用与文本编辑器意图、浏览器可渲染文档、WSL 转换与桌面可用性检查。它是库而非插件:没有 `ctx`、无状态、不发事件。
|
||||
`dsh-native-command` 无需 shell 即可运行 Host 可执行文件,并通过桌面打开 Host 文件系统路径。命令运行器捕获 utf8 输出、传播取消,并要求显式指定 Windows 启动时的窗口可见性。路径打开器支持默认应用与文本编辑器意图、浏览器可渲染文档、WSL 转换与桌面可用性检查。它是库而非插件:没有 `ctx`、无状态、不发事件。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -34,10 +34,10 @@ import { runNativeCommand } from '@deepseek-ai/dsh-native-command'
|
||||
|
||||
declare const script: string
|
||||
declare const signal: AbortSignal
|
||||
const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], signal)
|
||||
const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], signal, 'hidden')
|
||||
```
|
||||
|
||||
退出码为 0 时,调用解析为捕获到的 stdout 与 stderr。任何失败都会以错误拒绝,错误附带退出 `code` 与两路已捕获输出,因此调用方无需重跑命令即可区分工具缺失(`ENOENT`)、取消(`ABORT_ERR`)与真实的命令失败。
|
||||
退出码为 0 时,调用解析为捕获到的 stdout 与 stderr。任何失败都会以错误拒绝,错误附带退出 `code` 与两路已捕获输出,因此调用方无需重跑命令即可区分工具缺失(`ENOENT`)、取消(`ABORT_ERR`)与真实的命令失败。必填的第四个参数 `window: 'hidden' | 'visible'` 控制 Windows 启动时的窗口可见性,在其他平台不生效。后台命令传入 `'hidden'`,GUI 启动程序传入 `'visible'`。路径打开器为 Explorer 选择 `'visible'`,包括 WSL 转交和文件选中操作,因为隐藏 Explorer 也会隐藏它的文件夹窗口。
|
||||
|
||||
### 注入命令边界
|
||||
|
||||
@@ -72,7 +72,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s
|
||||
|
||||
### execFile 给了运行器什么
|
||||
|
||||
`execFile` 以 argv 数组直接 spawn 可执行文件——没有 shell 字符串,参数不经 shell 解释。`signal` 选项在调用方中止触发时终止子进程;`windowsHide` 在 Windows 上抑制瞬时控制台窗口。遇到非零退出或 spawn 错误时,回调把 `code`、`stdout`、`stderr` 挂到被拒绝的错误上,并保留原始错误作为 `cause`。
|
||||
`execFile` 以 argv 数组直接 spawn 可执行文件——没有 shell 字符串,参数不经 shell 解释。`signal` 选项在调用方中止触发时终止子进程;`window` 由运行器映射为 Node 的 `windowsHide` 选项。遇到非零退出或 spawn 错误时,回调把 `code`、`stdout`、`stderr` 挂到被拒绝的错误上,并保留原始错误作为 `cause`。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -37,10 +37,10 @@ async function desktopFile(root: string, id: string, directory = root): Promise<
|
||||
export async function linuxFileApplications(
|
||||
path: string, signal: AbortSignal, run: NativeCommandRunner, env: NodeJS.ProcessEnv,
|
||||
): Promise<readonly NativeFileApplication[]> {
|
||||
const info = await run('gio', ['info', '-a', 'standard::content-type', path], signal)
|
||||
const info = await run('gio', ['info', '-a', 'standard::content-type', path], signal, 'hidden')
|
||||
const mime = /standard::content-type:\s*(\S+)/.exec(info.stdout)?.[1]
|
||||
if (mime === undefined) throw new Error('GIO did not identify the file content type')
|
||||
const result = await run('env', ['LC_ALL=C', 'gio', 'mime', mime], signal)
|
||||
const result = await run('env', ['LC_ALL=C', 'gio', 'mime', mime], signal, 'hidden')
|
||||
const preferred = /^Default application.*:\s*(.+\.desktop)\s*$/m.exec(result.stdout)?.[1]
|
||||
const ids = [...new Set([...(preferred === undefined ? [] : [preferred]), ...result.stdout.split(/\r?\n/)
|
||||
.filter(line => /^\s+.*\.desktop\s*$/.test(line)).map(line => line.trim())])]
|
||||
|
||||
@@ -268,7 +268,7 @@ ${application === null ? 'ConvertTo-Json -InputObject @([DshFileAssociations]::L
|
||||
await writeFile(scriptPath, `\uFEFF${script}`, 'utf8')
|
||||
const result = await run('powershell.exe', [
|
||||
'-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
|
||||
], signal)
|
||||
], signal, 'hidden')
|
||||
return result.stdout
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
|
||||
@@ -71,7 +71,7 @@ async function queryFileApplications(
|
||||
const run = internals.run ?? runNativeCommand
|
||||
if (target.platform === 'linux') return linuxFileApplications(path, signal, run, internals.env ?? process.env)
|
||||
if (target.platform === 'darwin') {
|
||||
const { stdout } = await run('osascript', ['-l', 'JavaScript', '-e', MAC_APPLICATIONS, target.path, display ? 'icons' : 'handlers'], signal)
|
||||
const { stdout } = await run('osascript', ['-l', 'JavaScript', '-e', MAC_APPLICATIONS, target.path, display ? 'icons' : 'handlers'], signal, 'hidden')
|
||||
const applications = parseMacApplications(JSON.parse(stdout))
|
||||
return display ? dedupeMacApplications(applications) : applications
|
||||
}
|
||||
@@ -179,8 +179,8 @@ export async function openNativeFileApplication(
|
||||
}
|
||||
const apps = await queryFileApplications(path, signal, internals, false)
|
||||
if (!apps.some(app => app.id === application)) throw new Error('Application is not registered for this file')
|
||||
if (target.platform === 'linux') await run('gio', ['launch', application, path], signal)
|
||||
else await run('open', ['-a', application, path], signal)
|
||||
if (target.platform === 'linux') await run('gio', ['launch', application, path], signal, 'hidden')
|
||||
else await run('open', ['-a', application, path], signal, 'hidden')
|
||||
}
|
||||
|
||||
/** Resolve the desktop that owns the file, including Windows applications reached from WSL. */
|
||||
@@ -190,7 +190,7 @@ async function desktopTarget(
|
||||
signal.throwIfAborted()
|
||||
const platform = internals.platform ?? process.platform
|
||||
if (platform === 'linux' && nativeFileManager(internals) === 'explorer') {
|
||||
const translated = await (internals.run ?? runNativeCommand)('wslpath', ['-w', path], signal)
|
||||
const translated = await (internals.run ?? runNativeCommand)('wslpath', ['-w', path], signal, 'hidden')
|
||||
signal.throwIfAborted()
|
||||
const windowsPath = translated.stdout.replace(/[\r\n]+$/, '')
|
||||
if (windowsPath === '') throw new Error('wslpath returned no Windows path')
|
||||
|
||||
@@ -58,7 +58,7 @@ async function openInBrowser(
|
||||
let bundle: string | undefined
|
||||
try {
|
||||
const { stdout } = await run(
|
||||
'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal)
|
||||
'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal, 'hidden')
|
||||
bundle = macBundleForHttps(stdout)
|
||||
} catch {
|
||||
// No LaunchServices record (a fresh account never changed a default):
|
||||
@@ -66,7 +66,7 @@ async function openInBrowser(
|
||||
return false
|
||||
}
|
||||
if (bundle === undefined) return false
|
||||
await run('open', ['-b', bundle, path], signal)
|
||||
await run('open', ['-b', bundle, path], signal, 'hidden')
|
||||
return true
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
@@ -74,7 +74,7 @@ async function openInBrowser(
|
||||
// xdg-settings needs a launcher this package has no business shipping.
|
||||
const browser = env.BROWSER
|
||||
if (browser === undefined || browser === '') return false
|
||||
await run(browser, [path], signal)
|
||||
await run(browser, [path], signal, 'hidden')
|
||||
return true
|
||||
}
|
||||
// Windows names no browser without reading the UserChoice registry, and its
|
||||
@@ -135,7 +135,8 @@ function explorerTarget(windowsPath: string): string {
|
||||
*/
|
||||
async function runExplorer(args: readonly string[], signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
|
||||
try {
|
||||
await run('explorer.exe', args, signal)
|
||||
// SW_HIDE also hides Explorer's folder window, not just console windows.
|
||||
await run('explorer.exe', args, signal, 'visible')
|
||||
} catch (error: unknown) {
|
||||
signal.throwIfAborted()
|
||||
// Explorer can exit 1 after delegating to the existing desktop process.
|
||||
@@ -156,7 +157,7 @@ async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpene
|
||||
|
||||
/** Translate a WSL path before handing it to the Windows desktop. */
|
||||
async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
|
||||
const translated = await run('wslpath', ['-w', path], signal)
|
||||
const translated = await run('wslpath', ['-w', path], signal, 'hidden')
|
||||
signal.throwIfAborted()
|
||||
const windowsPath = translated.stdout.replace(/[\r\n]+$/, '')
|
||||
if (windowsPath === '') throw new Error('wslpath returned no Windows path')
|
||||
@@ -179,7 +180,7 @@ async function openNativePathWithIntent(
|
||||
&& await openInBrowser(path, signal, platform, run, env)) return
|
||||
|
||||
if (platform === 'darwin') {
|
||||
await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal)
|
||||
await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal, 'hidden')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -193,7 +194,7 @@ async function openNativePathWithIntent(
|
||||
await openWslPath(path, signal, run)
|
||||
return
|
||||
}
|
||||
await run('xdg-open', [path], signal)
|
||||
await run('xdg-open', [path], signal, 'hidden')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -292,13 +293,13 @@ export async function revealNativePath(
|
||||
const run = internals.run ?? runNativeCommand
|
||||
const manager = nativeFileManager({ ...internals, platform })
|
||||
if (manager === 'finder') {
|
||||
await run('open', ['-R', path], signal)
|
||||
await run('open', ['-R', path], signal, 'hidden')
|
||||
return
|
||||
}
|
||||
if (manager === 'explorer') {
|
||||
let windowsPath = path
|
||||
if (platform === 'linux') {
|
||||
const translated = await run('wslpath', ['-w', path], signal)
|
||||
const translated = await run('wslpath', ['-w', path], signal, 'hidden')
|
||||
signal.throwIfAborted()
|
||||
windowsPath = translated.stdout.replace(/[\r\n]+$/, '')
|
||||
if (windowsPath === '') throw new Error('wslpath returned no Windows path')
|
||||
@@ -307,7 +308,7 @@ export async function revealNativePath(
|
||||
return
|
||||
}
|
||||
if (manager === 'directory') {
|
||||
await run('xdg-open', [dirname(path)], signal)
|
||||
await run('xdg-open', [dirname(path)], signal, 'hidden')
|
||||
return
|
||||
}
|
||||
throw new Error(`native file manager is unsupported on ${platform}`)
|
||||
|
||||
@@ -10,21 +10,24 @@ export type NativeCommandRunner = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
signal: AbortSignal,
|
||||
window: 'hidden' | 'visible',
|
||||
) => Promise<{ stdout: string; stderr: string }>
|
||||
|
||||
/**
|
||||
* Run a host command with utf8 stdio, abort propagation, and Windows hide.
|
||||
* Run a host command with utf8 stdio, abort propagation, and explicit GUI visibility.
|
||||
* @param command - executable path or PATH name.
|
||||
* @param args - argv (never a shell string).
|
||||
* @param signal - caller/connection lifetime; abort terminates the child.
|
||||
* @param window - Windows startup visibility: `hidden` for background commands,
|
||||
* `visible` for GUI launchers. Ignored on other platforms.
|
||||
* @returns captured stdout/stderr on exit 0.
|
||||
*/
|
||||
export const runNativeCommand: NativeCommandRunner = (command, args, signal) =>
|
||||
export const runNativeCommand: NativeCommandRunner = (command, args, signal, window) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
command,
|
||||
[...args],
|
||||
{ encoding: 'utf8', signal, windowsHide: true },
|
||||
{ encoding: 'utf8', signal, windowsHide: window === 'hidden' },
|
||||
(error, stdout, stderr) => {
|
||||
if (error !== null) {
|
||||
const failure = Object.assign(new Error(error.message, { cause: error }), {
|
||||
|
||||
@@ -31,7 +31,7 @@ it('deduplicates GIO handlers, resolves nested ids, localizes names, and reuses
|
||||
{ id: join(root, 'applications', 'nested', 'other.desktop'), name: 'Other', default: false, icon: null },
|
||||
])
|
||||
await openNativeFileApplication('/file.mp3', apps[0]!.id, signal, facts)
|
||||
expect(run).toHaveBeenLastCalledWith('gio', ['launch', apps[0]!.id, '/file.mp3'], signal)
|
||||
expect(run).toHaveBeenLastCalledWith('gio', ['launch', apps[0]!.id, '/file.mp3'], signal, 'hidden')
|
||||
await expect(openNativeFileApplication('/file.mp3', '/unregistered.desktop', signal, facts)).rejects.toThrow('not registered')
|
||||
})
|
||||
|
||||
|
||||
@@ -29,14 +29,14 @@ it.skipIf(process.platform !== 'win32')('queries and invokes a registered Window
|
||||
const opened = await readFile(marker, 'utf8').catch(() => '(no handoff marker)')
|
||||
console.error('Windows association fixture:', phase, 'active native commands:', active.size, opened)
|
||||
})
|
||||
const run: NativeCommandRunner = (command, args, operationSignal) => {
|
||||
const pending = runNativeCommand(command, args, operationSignal)
|
||||
const run: NativeCommandRunner = (command, args, operationSignal, window) => {
|
||||
const pending = runNativeCommand(command, args, operationSignal, window)
|
||||
active.add(pending)
|
||||
void pending.then(() => active.delete(pending), () => active.delete(pending))
|
||||
return pending
|
||||
}
|
||||
const runScript = async (source: string): Promise<void> => {
|
||||
await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')], signal)
|
||||
await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')], signal, 'hidden')
|
||||
}
|
||||
onTestFinished(async () => {
|
||||
lifetime.abort()
|
||||
@@ -47,7 +47,7 @@ $root = [Microsoft.Win32.Registry]::CurrentUser
|
||||
$root.DeleteSubKeyTree('Software\\Classes\\${extension}', $false)
|
||||
$root.DeleteSubKeyTree('Software\\Classes\\${progId}', $false)
|
||||
$root.DeleteSubKeyTree('Software\\Classes\\Applications\\${appName}', $false)
|
||||
`, 'utf16le').toString('base64')], new AbortController().signal)
|
||||
`, 'utf16le').toString('base64')], new AbortController().signal, 'hidden')
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
await writeFile(path, 'test')
|
||||
@@ -65,7 +65,7 @@ public static class Handler {
|
||||
'@
|
||||
`)
|
||||
phase = 'verify fixture executable'
|
||||
await run(executable, [path], signal)
|
||||
await run(executable, [path], signal, 'hidden')
|
||||
expect((await readFile(marker, 'utf8')).split(/\r?\n/)[0]).toBe(path)
|
||||
await rm(marker)
|
||||
phase = 'register association'
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('native file associations', () => {
|
||||
const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' }))
|
||||
const path = '/tmp/中文 $(touch nope).mp3'
|
||||
await expect(nativeFileApplications(path, signal, { platform: 'darwin', run })).resolves.toEqual([application])
|
||||
expect(run).toHaveBeenCalledWith('osascript', ['-l', 'JavaScript', '-e', expect.any(String), path, 'icons'], signal)
|
||||
expect(run).toHaveBeenCalledWith('osascript', ['-l', 'JavaScript', '-e', expect.any(String), path, 'icons'], signal, 'hidden')
|
||||
})
|
||||
|
||||
it.each(['{}', '[null]', '[{"id":1}]', '[{"id":"/a.app","default":false,"icon":null,"bundle":"b"}]', '[{"id":"/a.app","name":"A","default":false,"icon":null,"bundle":5}]', JSON.stringify([{ ...application, icon: 'javascript:alert(1)' }])])('rejects malformed native output %s', async (stdout) => {
|
||||
@@ -79,8 +79,8 @@ describe('native file associations', () => {
|
||||
it('launches only a currently registered application with argv', async () => {
|
||||
const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' }))
|
||||
await openNativeFileApplication('/file.mp3', application.id, signal, { platform: 'darwin', run })
|
||||
expect(run).toHaveBeenNthCalledWith(1, 'osascript', ['-l', 'JavaScript', '-e', expect.any(String), '/file.mp3', 'handlers'], signal)
|
||||
expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal)
|
||||
expect(run).toHaveBeenNthCalledWith(1, 'osascript', ['-l', 'JavaScript', '-e', expect.any(String), '/file.mp3', 'handlers'], signal, 'hidden')
|
||||
expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal, 'hidden')
|
||||
run.mockClear()
|
||||
await expect(openNativeFileApplication('/file.mp3', '/arbitrary.app', signal, { platform: 'darwin', run })).rejects.toThrow('not registered')
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
@@ -95,7 +95,7 @@ describe('native file associations', () => {
|
||||
const shown = await nativeFileApplications('/file.mp3', signal, { platform: 'darwin', run })
|
||||
expect(shown.map(app => app.id)).toEqual([application.id])
|
||||
await openNativeFileApplication('/file.mp3', '/updates/Music.app', signal, { platform: 'darwin', run })
|
||||
expect(run).toHaveBeenLastCalledWith('open', ['-a', '/updates/Music.app', '/file.mp3'], signal)
|
||||
expect(run).toHaveBeenLastCalledWith('open', ['-a', '/updates/Music.app', '/file.mp3'], signal, 'hidden')
|
||||
})
|
||||
|
||||
it('does not query after cancellation or on unsupported platforms', async () => {
|
||||
@@ -114,7 +114,7 @@ it('uses the production command adapter and current platform when no override is
|
||||
onTestFinished(() => { run.mockRestore() })
|
||||
expect(await nativeFileApplications('/file.mp3', signal)).toEqual(process.platform === 'linux' ? [] : [application])
|
||||
await openNativeFileApplication('/file.mp3', application.id, signal, { platform: 'darwin' })
|
||||
expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal)
|
||||
expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal, 'hidden')
|
||||
})
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ it('uses the Windows desktop for WSL paths and rejects an empty translation', as
|
||||
const facts = { platform: 'linux' as const, osRelease: 'microsoft', env: {}, run }
|
||||
expect(await nativeFileApplications('/mnt/c/音频.mp3', signal, facts)).toEqual([application])
|
||||
await openNativeFileApplication('/mnt/c/音频.mp3', application.id, signal, facts)
|
||||
expect(run).toHaveBeenCalledWith('wslpath', ['-w', '/mnt/c/音频.mp3'], signal)
|
||||
expect(run).toHaveBeenCalledWith('wslpath', ['-w', '/mnt/c/音频.mp3'], signal, 'hidden')
|
||||
const empty = async () => ({ stdout: '', stderr: '' })
|
||||
await expect(nativeFileApplications('/a', signal, { ...facts, run: empty })).rejects.toThrow('no Windows path')
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ describe('runNativeCommand', () => {
|
||||
const result = await runNativeCommand(
|
||||
node,
|
||||
['-e', 'process.stdout.write("out✓"); process.stderr.write("err")'],
|
||||
new AbortController().signal,
|
||||
new AbortController().signal, 'hidden',
|
||||
)
|
||||
expect(result).toEqual({ stdout: 'out✓', stderr: 'err' })
|
||||
})
|
||||
@@ -17,7 +17,7 @@ describe('runNativeCommand', () => {
|
||||
const failure = await runNativeCommand(
|
||||
node,
|
||||
['-e', 'process.stdout.write("partial"); process.stderr.write("boom"); process.exit(3)'],
|
||||
new AbortController().signal,
|
||||
new AbortController().signal, 'hidden',
|
||||
).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error)
|
||||
expect(failure).toMatchObject({ code: 3, stdout: 'partial', stderr: 'boom' })
|
||||
expect((failure as Error).cause).toBeInstanceOf(Error)
|
||||
@@ -27,14 +27,14 @@ describe('runNativeCommand', () => {
|
||||
const failure = await runNativeCommand(
|
||||
'dsh-definitely-missing-command',
|
||||
[],
|
||||
new AbortController().signal,
|
||||
new AbortController().signal, 'hidden',
|
||||
).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error)
|
||||
expect(failure).toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('terminates the child when the signal aborts', async () => {
|
||||
const abort = new AbortController()
|
||||
const pending = runNativeCommand(node, ['-e', 'setTimeout(() => {}, 60_000)'], abort.signal)
|
||||
const pending = runNativeCommand(node, ['-e', 'setTimeout(() => {}, 60_000)'], abort.signal, 'hidden')
|
||||
abort.abort()
|
||||
const failure = await pending.then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
|
||||
@@ -16,22 +16,24 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>()
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import { release as osRelease } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { canOpenNativePath, nativeFileManager, revealNativePath, openNativeAssociatedPath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/index.ts'
|
||||
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
beforeEach(() => { execFileMock.mockReset() })
|
||||
|
||||
describe('native path opener', () => {
|
||||
it('opens with macOS open(1)', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath('/Users/test/file.txt', signal(), { platform: 'darwin', run })
|
||||
expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal))
|
||||
expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal), 'hidden')
|
||||
})
|
||||
|
||||
it('bypasses macOS file associations for text documents', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativeTextFile('/Users/test/settings.yaml', signal(), { platform: 'darwin', run })
|
||||
expect(run).toHaveBeenCalledWith('open', ['-t', '/Users/test/settings.yaml'], expect.any(AbortSignal))
|
||||
expect(run).toHaveBeenCalledWith('open', ['-t', '/Users/test/settings.yaml'], expect.any(AbortSignal), 'hidden')
|
||||
})
|
||||
|
||||
it('uses the Linux desktop association for text documents', async () => {
|
||||
@@ -39,7 +41,7 @@ describe('native path opener', () => {
|
||||
await openNativeTextFile('/tmp/settings.yaml', signal(), {
|
||||
platform: 'linux', osRelease: '6.8.0-generic', env: {}, run,
|
||||
})
|
||||
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal))
|
||||
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal), 'hidden')
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -55,8 +57,8 @@ describe('native path opener', () => {
|
||||
platform: 'linux', osRelease, env, run,
|
||||
})
|
||||
expect(run.mock.calls).toEqual([
|
||||
['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal],
|
||||
['explorer.exe', ['file://wsl.localhost/Ubuntu/home/test%20user/settings.yaml'], requestSignal],
|
||||
['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal, 'hidden'],
|
||||
['explorer.exe', ['file://wsl.localhost/Ubuntu/home/test%20user/settings.yaml'], requestSignal, 'visible'],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -107,7 +109,7 @@ describe('native path opener', () => {
|
||||
] as const)('opens %s through the one encoded Explorer target', async (path, target) => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath(path, signal(), { platform: 'win32', run })
|
||||
expect(run).toHaveBeenCalledExactlyOnceWith('explorer.exe', [target], expect.any(AbortSignal))
|
||||
expect(run).toHaveBeenCalledExactlyOnceWith('explorer.exe', [target], expect.any(AbortSignal), 'visible')
|
||||
})
|
||||
|
||||
// Node restores a trailing separator against the host's `path.sep`, so a
|
||||
@@ -120,6 +122,7 @@ describe('native path opener', () => {
|
||||
'explorer.exe',
|
||||
[process.platform === 'win32' ? 'file:///C:/' : 'file:///C://'],
|
||||
expect.any(AbortSignal),
|
||||
'visible',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -130,6 +133,7 @@ describe('native path opener', () => {
|
||||
'explorer.exe',
|
||||
['file:///C:/work/dir%2Ccomma'],
|
||||
expect.any(AbortSignal),
|
||||
'visible',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -140,6 +144,7 @@ describe('native path opener', () => {
|
||||
'explorer.exe',
|
||||
['file:///C:/work/settings.yaml'],
|
||||
expect.any(AbortSignal),
|
||||
'visible',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -149,7 +154,7 @@ describe('native path opener', () => {
|
||||
platform: 'linux', osRelease: '6.8.0-generic',
|
||||
env: { WSL_DISTRO_NAME: '', WSL_INTEROP: '' }, run,
|
||||
})
|
||||
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal))
|
||||
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal), 'hidden')
|
||||
})
|
||||
|
||||
it('rejects unsupported platforms', async () => {
|
||||
@@ -181,6 +186,18 @@ describe('native path opener', () => {
|
||||
expect(run.mock.calls[0]?.[0]).toBe(ambientWsl ? 'wslpath' : 'xdg-open')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['open', openNativePath],
|
||||
['reveal', revealNativePath],
|
||||
] as const)('keeps Explorer windows visible through the default %s adapter', async (_intent, open) => {
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(null, '', '')
|
||||
})
|
||||
await open('C:\\work\\report.txt', signal(), { platform: 'win32' })
|
||||
expect(execFileMock.mock.lastCall?.[0]).toBe('explorer.exe')
|
||||
expect(execFileMock.mock.lastCall?.[2].windowsHide).toBe(false)
|
||||
})
|
||||
|
||||
it('runs the default command adapter without a shell and preserves command failures', async () => {
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(null, '', '')
|
||||
@@ -368,7 +385,8 @@ describe('native file manager', () => {
|
||||
const internals = { platform, env: {}, osRelease: 'generic', run }
|
||||
expect(nativeFileManager(internals)).toBe(manager)
|
||||
await revealNativePath(path, signal(), internals)
|
||||
expect(run).toHaveBeenCalledExactlyOnceWith(command, args, expect.any(AbortSignal))
|
||||
expect(run).toHaveBeenCalledExactlyOnceWith(command, args, expect.any(AbortSignal),
|
||||
platform === 'win32' ? 'visible' : 'hidden')
|
||||
})
|
||||
|
||||
it('selects a translated WSL path in Explorer and never starts a Linux file manager', async () => {
|
||||
@@ -459,7 +477,7 @@ it.each([
|
||||
])('preserves special characters in the Explorer target %s', async (path, target) => {
|
||||
const run = vi.fn<PathOpenerRunner>().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
await revealNativePath(path, signal(), { platform: 'win32', run })
|
||||
expect(run).toHaveBeenCalledWith('explorer.exe', ['/select,', target], expect.any(AbortSignal))
|
||||
expect(run).toHaveBeenCalledWith('explorer.exe', ['/select,', target], expect.any(AbortSignal), 'visible')
|
||||
})
|
||||
|
||||
|
||||
@@ -471,6 +489,6 @@ it.each(['.html', '.svg'])('uses the file association for %s despite a configure
|
||||
await openNativeAssociatedPath(path, lifetime, {
|
||||
platform, osRelease: '6.8.0-generic', env: { BROWSER: 'custom-browser' }, run,
|
||||
})
|
||||
expect(run.mock.calls).toEqual([[platform === 'darwin' ? 'open' : 'xdg-open', [path], lifetime]])
|
||||
expect(run.mock.calls).toEqual([[platform === 'darwin' ? 'open' : 'xdg-open', [path], lifetime, 'hidden']])
|
||||
}
|
||||
})
|
||||
Generated
+7
-7
@@ -667,7 +667,7 @@ importers:
|
||||
specifier: ^4.2.0
|
||||
version: 4.3.1
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
pnpm:
|
||||
specifier: 11.7.0
|
||||
@@ -8389,7 +8389,7 @@ importers:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
@@ -8991,7 +8991,7 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../util/native-command
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
@@ -10279,7 +10279,7 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../subprocess/win32-process
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
yaml:
|
||||
specifier: ^2.4.2
|
||||
@@ -10901,7 +10901,7 @@ importers:
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
@@ -12758,7 +12758,7 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../win32-process
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
node-pty:
|
||||
specifier: 1.2.0-beta.15
|
||||
@@ -12783,7 +12783,7 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../util/lazy-require
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
|
||||
@@ -40,6 +40,9 @@ allowBuilds:
|
||||
protobufjs: false
|
||||
node-addon-require-builtin: false
|
||||
# JSONL durability calls MoveFileExW with write-through publication on Windows.
|
||||
# Consumers pin 3.1.1: Koffi 3.3.2's source build fails under GCC 13 when
|
||||
# optional prebuilts are omitted. Upgrade all consumers together after the
|
||||
# replacement passes that build and the release packed-install check.
|
||||
koffi: true
|
||||
# The Python runtime deploy includes the reviewed workspace postinstall that
|
||||
# restores the executable bit on node-pty's macOS spawn helper.
|
||||
|
||||
@@ -19,7 +19,7 @@ import { renderPersistencePair, type PersistenceArtifact } from './persistence-a
|
||||
|
||||
export { annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes } from './persistence-catalog-source.ts'
|
||||
export type { AnnotatedLogEventEntry, EventEnvelopeTypeEntry, LogEventEntry } from './persistence-catalog-source.ts'
|
||||
import type { PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
import { persistenceSchemaSnapshot, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -178,7 +178,7 @@ export function persistenceCatalogArtifacts(scanRoot: string, schema: Persistenc
|
||||
return [
|
||||
...renderPersistencePair(scanRoot, OUT, render(events, envelope, schema), render(events, envelope, schema, 'zh')),
|
||||
{ path: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
|
||||
{ path: OUT_SCHEMA, content: `${JSON.stringify(schema, null, 2)}\n` },
|
||||
{ path: OUT_SCHEMA, content: `${JSON.stringify(persistenceSchemaSnapshot(schema), null, 2)}\n` },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { JSON_SCHEMA, load } from 'js-yaml'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import { canonicalizeSchema, reachableSchemaTypes, schemaDigest } from './persistence-schema-model.ts'
|
||||
import { matchingSourceCompatibility, sourceKindGroups, validSourceCompatibility } from './persistence-source-policy.ts'
|
||||
import type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
@@ -265,7 +265,7 @@ function parseSchema(value: unknown, label: string, formatVersion: 1 | 2): Canon
|
||||
return schema
|
||||
}
|
||||
|
||||
/** Parse a persisted schema inventory, rejecting malformed graphs and digest drift.
|
||||
/** Parse full or compact inventories, restoring type graphs and rejecting invalid digests.
|
||||
* @param value - JSON read from the current inventory or an enforced acknowledgement snapshot.
|
||||
* @returns the validated inventory.
|
||||
*/
|
||||
@@ -284,8 +284,10 @@ export function parseHistoricalPersistenceSnapshot(value: unknown): PersistenceS
|
||||
function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaInventory {
|
||||
const input = record(value, 'schema inventory')
|
||||
keys(input, ['formatVersion', 'roots', 'types'], 'schema inventory')
|
||||
if (input.formatVersion !== 1 && input.formatVersion !== 2) throw new Error('unsupported persistence schema normalization version')
|
||||
const formatVersion = input.formatVersion
|
||||
if (formatVersion !== 1 && formatVersion !== 2) throw new Error('unsupported persistence schema normalization version')
|
||||
const names = new Set<string>()
|
||||
const schemas: CanonicalSchema[] = []
|
||||
for (const rawRoot of array(input.roots, 'schema roots')) {
|
||||
const root = record(rawRoot, 'schema root')
|
||||
keys(root, ['key', 'kind', 'digest', 'schema'], 'schema root', ['event', 'surface'])
|
||||
@@ -298,24 +300,37 @@ function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaIn
|
||||
} else if ((root.kind !== 'header' || !['SessionHeader', 'JsonlHeaderLine'].includes(key))
|
||||
&& (root.kind !== 'envelope' || key !== 'SessionEventEnvelope')) throw new Error(`invalid schema root ${key}`)
|
||||
if (root.kind !== 'event' && (root.event !== undefined || root.surface !== undefined)) throw new Error(`${key}: non-event metadata`)
|
||||
const schema = parseSchema(root.schema, key, input.formatVersion)
|
||||
const schema = parseSchema(root.schema, key, formatVersion)
|
||||
if (root.kind === 'event') validateEventMetadata(schema, String(root.event), root.surface === true, historical)
|
||||
if (digest(root.digest, `${key} digest`) !== schemaDigest(schema)) throw new Error(`${key}: schema digest mismatch`)
|
||||
schemas.push(schema)
|
||||
}
|
||||
for (const rawType of array(input.types, 'schema types')) {
|
||||
let reachable: Map<string, CanonicalSchema> | undefined
|
||||
const types = array(input.types, 'schema types').map((rawType) => {
|
||||
const type = record(rawType, 'schema type')
|
||||
keys(type, ['digest', 'schema', 'names', 'sources'], 'schema type')
|
||||
const schema = parseSchema(type.schema, 'shared schema', input.formatVersion)
|
||||
if (digest(type.digest, 'shared digest') !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
|
||||
for (const name of array(type.names, 'type names')) textValue(name, 'type name')
|
||||
for (const source of array(type.sources, 'type sources')) {
|
||||
keys(type, ['digest', 'names', 'sources'], 'schema type', ['schema'])
|
||||
const typeDigest = digest(type.digest, 'shared digest')
|
||||
let schema: CanonicalSchema
|
||||
if ('schema' in type) {
|
||||
schema = parseSchema(type.schema, 'shared schema', formatVersion)
|
||||
if (typeDigest !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
|
||||
} else {
|
||||
reachable ??= reachableSchemaTypes(schemas)
|
||||
const found = reachable.get(typeDigest)
|
||||
if (found === undefined) throw new Error(`shared schema digest ${typeDigest} is not reachable from roots`)
|
||||
schema = found
|
||||
}
|
||||
const typeNames = array(type.names, 'type names').map(name => textValue(name, 'type name'))
|
||||
const sources = array(type.sources, 'type sources').map((source) => {
|
||||
const location = textValue(source, 'type source')
|
||||
if (historical && /:\d+(?::\d+)?$|#L\d+(?:-L\d+)?$/u.test(location)) {
|
||||
throw new Error('historical schema sources must omit line numbers')
|
||||
}
|
||||
}
|
||||
}
|
||||
return input as unknown as PersistenceSchemaInventory
|
||||
return location
|
||||
})
|
||||
return { digest: typeDigest, schema, names: typeNames, sources }
|
||||
})
|
||||
return { ...input as unknown as PersistenceSchemaInventory, types }
|
||||
}
|
||||
|
||||
function validateEventMetadata(schema: CanonicalSchema, event: string, surface: boolean, historical: boolean): void {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
|
||||
/** Recorded reader promise for explicitly attributed source additions. */
|
||||
export interface SourceCompatibility {
|
||||
@@ -71,6 +72,53 @@ export interface PersistenceSchemaInventory {
|
||||
readonly types: readonly PersistenceType[]
|
||||
}
|
||||
|
||||
/** Type metadata with an explicit graph when roots cannot reconstruct it exactly. */
|
||||
export interface PersistenceTypeIndex extends Omit<PersistenceType, 'schema'> {
|
||||
readonly schema?: CanonicalSchema
|
||||
}
|
||||
|
||||
/** Compact inventory representation; formatVersion still pins normalization. */
|
||||
export interface PersistenceSchemaSnapshot {
|
||||
readonly formatVersion: PersistenceSchemaInventory['formatVersion']
|
||||
readonly roots: readonly PersistenceRoot[]
|
||||
readonly types: readonly PersistenceTypeIndex[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Index every normalized type reachable from the supplied graphs.
|
||||
* @param schemas - complete canonical root graphs.
|
||||
* @returns canonical subgraphs keyed by their structural digest.
|
||||
*/
|
||||
export function reachableSchemaTypes(schemas: readonly CanonicalSchema[]): Map<string, CanonicalSchema> {
|
||||
const types = new Map<string, CanonicalSchema>()
|
||||
for (const schema of schemas) {
|
||||
for (const [index] of schema.nodes.entries()) {
|
||||
const type = canonicalizeSchema(schema.nodes, index)
|
||||
types.set(schemaDigest(type), type)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/**
|
||||
* Omit type graphs exactly reconstructable from roots, retaining all other graphs.
|
||||
* @param inventory - expanded inventory, including types erased from roots by normalization.
|
||||
* @returns a lossless snapshot preserving root graphs, type order, and declaration metadata.
|
||||
*/
|
||||
export function persistenceSchemaSnapshot(inventory: PersistenceSchemaInventory): PersistenceSchemaSnapshot {
|
||||
const reachable = reachableSchemaTypes(inventory.roots.map(root => root.schema))
|
||||
return {
|
||||
formatVersion: inventory.formatVersion,
|
||||
roots: inventory.roots,
|
||||
types: inventory.types.map(({ digest, schema, names, sources }) => ({
|
||||
digest,
|
||||
...(isDeepStrictEqual(schema, reachable.get(digest)) ? {} : { schema }),
|
||||
names,
|
||||
sources,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit direct graph edges in their normalized semantic order.
|
||||
* @param node - resolved graph node.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/** Compact inventories retain root fingerprints and restore shared recursive type graphs. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseHistoricalPersistenceSnapshot, parsePersistenceSnapshot } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, persistenceSchemaSnapshot, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
import { reviewPersistenceSchemas } from './persistence-review.ts'
|
||||
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
|
||||
|
||||
function inventory(): PersistenceSchemaInventory {
|
||||
const schema = canonicalizeSchema([
|
||||
{ kind: 'object', properties: [{ name: 'child', type: 1, optional: false }], indices: [] },
|
||||
{ kind: 'object', properties: [{ name: 'next', type: 1, optional: true }, { name: 'value', type: 2, optional: false }], indices: [] },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
], 0)
|
||||
return {
|
||||
formatVersion: 1,
|
||||
roots: ['SessionHeader', 'JsonlHeaderLine'].map(key => ({ key, kind: 'header', digest: schemaDigest(schema), schema })),
|
||||
types: schema.nodes.map((_, index) => {
|
||||
const type = canonicalizeSchema(schema.nodes, index)
|
||||
return { digest: schemaDigest(type), schema: type, names: index === 1 ? ['Recursive'] : [], sources: ['packages/example/src/types.ts'] }
|
||||
}).sort((left, right) => left.digest.localeCompare(right.digest)),
|
||||
}
|
||||
}
|
||||
|
||||
describe('compact persistence snapshots', () => {
|
||||
it('stores only root graphs and restores shared recursive definitions without changing reports', () => {
|
||||
const original = inventory()
|
||||
const snapshot = persistenceSchemaSnapshot(original)
|
||||
expect(snapshot.roots).toEqual(original.roots)
|
||||
expect(snapshot.types).toEqual(original.types.map(({ digest, names, sources }) => ({ digest, names, sources })))
|
||||
const restored = parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))
|
||||
expect(restored).toEqual(original)
|
||||
expect(reviewPersistenceSchemas(original, restored)).toEqual(reviewPersistenceSchemas(original, original))
|
||||
for (const rendering of ['current', 'historical'] as const) {
|
||||
expect(renderPersistenceSchemaIndex(restored, 'en', undefined, 2, rendering))
|
||||
.toBe(renderPersistenceSchemaIndex(original, 'en', undefined, 2, rendering))
|
||||
expect(renderPersistenceSchemaDefinitions(restored, 'en', undefined, 2, rendering))
|
||||
.toBe(renderPersistenceSchemaDefinitions(original, 'en', undefined, 2, rendering))
|
||||
}
|
||||
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original)
|
||||
})
|
||||
|
||||
it.each([true, false])('retains independent recursive types with root graphs=%s', (withRoots) => {
|
||||
const base = inventory()
|
||||
const schema = canonicalizeSchema([
|
||||
{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }, { name: 'flag', type: 1, optional: false }], indices: [] },
|
||||
{ kind: 'literal', value: false },
|
||||
], 0)
|
||||
const independent = { digest: schemaDigest(schema), schema, names: ['example.ts#Independent', 'example.ts#Alias'], sources: ['example.ts'] }
|
||||
const original = { ...base, roots: withRoots ? base.roots : [], types: [independent, ...base.types] }
|
||||
const snapshot = persistenceSchemaSnapshot(original)
|
||||
expect(snapshot.types[0]).toEqual(independent)
|
||||
if (withRoots) expect(snapshot.types.slice(1).every(type => type.schema === undefined)).toBe(true)
|
||||
else expect(snapshot.types).toEqual(original.types)
|
||||
const restored = parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))
|
||||
expect(restored).toEqual(original)
|
||||
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original)
|
||||
expect(persistenceSchemaSnapshot(restored)).toEqual(snapshot)
|
||||
expect(renderPersistenceSchemaDefinitions(restored)).toBe(renderPersistenceSchemaDefinitions(original))
|
||||
})
|
||||
|
||||
it('reads legacy full graphs and keeps partial acknowledgement inventories empty', () => {
|
||||
const original = inventory()
|
||||
expect(parsePersistenceSnapshot(JSON.parse(JSON.stringify(original)))).toEqual(original)
|
||||
const partial = { ...original, types: [] }
|
||||
expect(parsePersistenceSnapshot(persistenceSchemaSnapshot(partial))).toEqual(partial)
|
||||
})
|
||||
|
||||
it('rejects compact digests that do not identify a root subgraph', () => {
|
||||
const snapshot = persistenceSchemaSnapshot(inventory())
|
||||
const schema = canonicalizeSchema([{ kind: 'literal', value: 'unreachable' }], 0)
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ digest: schemaDigest(schema), names: [], sources: [] }] }))
|
||||
.toThrow('is not reachable from roots')
|
||||
})
|
||||
|
||||
it('still validates explicit legacy graphs instead of replacing them with root graphs', () => {
|
||||
const original = inventory()
|
||||
const schema = canonicalizeSchema([{ kind: 'primitive', type: 'number' }], 0)
|
||||
expect(() => parsePersistenceSnapshot({ ...original, types: original.types.map(type => ({ ...type, schema })) }))
|
||||
.toThrow('shared schema digest mismatch')
|
||||
})
|
||||
|
||||
it('rejects invalid metadata and malformed explicit schemas in compact inventories', () => {
|
||||
const snapshot = persistenceSchemaSnapshot(inventory())
|
||||
const type = snapshot.types[0]!
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, names: [3] }] })).toThrow('type name')
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, sources: [3] }] })).toThrow('type source')
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, schema: null }] })).toThrow('shared schema')
|
||||
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, types: [{ ...type, sources: ['types.ts:3'] }] }))
|
||||
.toThrow('must omit line numbers')
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,8 @@
|
||||
|
||||
- Table selection: forward and backward drags exclude later sections
|
||||
- Line-break highlight: transparent
|
||||
- Text selection: translucent blue in light and dark themes; canvas text remains visible
|
||||
- Mouse release preserves the selected text
|
||||
|
||||
## Image zoom
|
||||
|
||||
|
||||
Reference in new issue
Block a user