merge: 同步最新 master
This commit is contained in:
commit
aaa52be272
128 files changed
+1777
-470
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.md: b8a10a2150ead02036cb97506a25c3a51202adcf
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: adc0770cd00adeb3b13c95dcc386464493dcd943
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.md: f13f09d326dc7b4e7b2998f3124f70c185c50922
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: f9b563ebbf90e2ca19d1fc5ca9fa5b028e4429f1
|
||||
+7
-7
@@ -28,11 +28,11 @@ For a driver that is genuinely running, `steer()` and `inject()` are the same de
|
||||
|
||||
Injection is correct there. A cancelled turn is a user pressing stop, and reopening one on their behalf launders an interrupt into a model request they did not ask for. The turn loop already covers the ordinary case: it cannot close while the next-step inbox holds anything, so a notice arriving before that check extends the current turn, and several tasks settling together cost one step rather than one turn each.
|
||||
|
||||
### Waking is bounded, and the bound is not time
|
||||
### The cap on consecutive wakes is opt-in, and it is not time
|
||||
|
||||
`maxConsecutiveWakes` (default 3) caps the turns one owner may open this way; beyond it a notice degrades to injection and waits for the next turn. Claiming any user-authored message restores the budget — claiming, not arrival, because that is the point human input actually enters a step. Notices this plugin queued never refill it.
|
||||
Waking is unbounded unless `maxConsecutiveWakes` is set. With it set, the value caps the turns one owner may open this way; beyond it a notice degrades to injection and waits for the next turn. Claiming any user-authored message restores the budget — claiming, not arrival, because that is the point human input actually enters a step. Notices this plugin queued never refill it.
|
||||
|
||||
The bound exists because this chain is self-exciting in a way subagent settlement is not. Settlement is bounded by how many children the model spawned; a woken turn can start the background job whose completion wakes it again, with nobody watching. `dsh run` needs no separate policy: its one user message is claimed in the first turn and never repeats, so the budget is spent monotonically and the process terminates.
|
||||
The cap exists because this chain is self-exciting in a way subagent settlement is not. Settlement is bounded by how many children the model spawned; a woken turn can start the background job whose completion wakes it again, with nobody watching. It has no default because a notice past the cap stalls the session invisibly; the [unbounded-by-default decision](2026-09-22-unbounded-completion-wakes-by-default.md) records that trade-off. `dsh run` needs no separate policy either way: headless exits once the task turn goes idle, so a completion after that point has no owner to wake.
|
||||
|
||||
`completionDelivery: quiet` restores the old lane for idle owners. It exists for deterministic transcripts; job completion independently retains `quiet | wakeup` because its bounded owner-turn policy differs from next-step subagent reports.
|
||||
|
||||
@@ -52,15 +52,15 @@ The bound exists because this chain is self-exciting in a way subagent settlemen
|
||||
|
||||
**A general unsolicited-input queue** with priority lanes, as Claude Code uses to merge background jobs, cron, MCP push, and hooks into one drain. DSH's inbox already is that queue — durable `agent/inbox/spliced` splices over `next-turn`/`next-step` — so this would add a layer above an existing one to decide a single bit.
|
||||
|
||||
**Refusing to reopen a turn that already produced a visible answer,** Codex's `MailboxDeliveryPhase` latch. That latch is the default this decision deliberately inverts: waking after the model has spoken is the entire point, and the wake budget is the bound instead.
|
||||
**Refusing to reopen a turn that already produced a visible answer,** Codex's `MailboxDeliveryPhase` latch. That latch is the default this decision deliberately inverts: waking after the model has spoken is the entire point, and the optional wake budget is the bound instead.
|
||||
|
||||
**A wall-clock window** on top of the counter. For an interactive agent the slow case is the wanted one — an hour-long build finishing and the agent resuming is the feature — and `dsh run` is already bounded by the counter it cannot refill. Worth revisiting only if an unattended long-lived deployment appears.
|
||||
**A wall-clock window** on top of the counter. For an interactive agent the slow case is the wanted one — an hour-long build finishing and the agent resuming is the feature — and `dsh run` exits once its task turn goes idle regardless. Worth revisiting only if an unattended long-lived deployment appears.
|
||||
|
||||
**Suppressing `onJobDone` entirely during owner drain,** symmetric with the service-wide `listenersClosed`. It reads cleaner and removes a signal that is not only for notices: the force-fail record and the runtime invariant both observe teardown settlements. The `reported` bit denies exactly the reporters and nothing else.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Default behavior changes: an idle owner now spends a model request per completion, capped at `maxConsecutiveWakes` per owner between user messages. Deployments that want the old behavior set `completionDelivery: quiet`.
|
||||
- Default behavior changes: an idle owner now spends a model request per completion, capped per owner between user messages only where `maxConsecutiveWakes` is set. Deployments that want the old behavior set `completionDelivery: quiet`.
|
||||
- The `tool-jobs` prompt section needs no edit; "You are notified in-session when a task finishes" became true rather than aspirational.
|
||||
- `JobSnapshot.reported` gains teardown as a fourth setter, documented at the Service Definition and in [the subsystem reference](../../../../docs/subsystems/jobs.md).
|
||||
- `settle()` announces completion after committing the record and publishing the visible-set change. Any listener relying on running before waiters were released or before `onJobsChanged` now runs after both.
|
||||
@@ -69,7 +69,7 @@ The bound exists because this chain is self-exciting in a way subagent settlemen
|
||||
|
||||
### Accepted risks
|
||||
|
||||
A spent budget is restored only by user input. An unattended agent that exhausts it collects its remaining notices whenever something else opens a turn, and nothing re-arms it in the meantime.
|
||||
Under a configured `maxConsecutiveWakes`, a spent budget is restored only by user input. An unattended agent that exhausts it collects its remaining notices whenever something else opens a turn, nothing re-arms it in the meantime, and the client shows nothing while the notice waits. This is why the cap has no default.
|
||||
|
||||
A notice pending on an idle owner under `quiet` still dies with that owner's disposal, unchanged from before: the disposal cancel clears the unclaimed inbox and the log keeps the insert/cancel pair as the record. The [settlement delivery note](2026-08-06-manager-owned-subagent-settlement-delivery.md) owns the offline-mailbox discussion this would need.
|
||||
|
||||
|
||||
+7
-7
@@ -28,11 +28,11 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
|
||||
|
||||
在那里注入才是对的。轮次被取消意味着用户按了停止,替他们重新开一轮等于把一次中断洗成了他们没有要求的模型请求。普通情形已由轮次循环覆盖:只要 next-step inbox 还有内容,轮次就无法结束,因此在该检查之前抵达的通知会延长当前轮次,同时结算的多个任务只花掉一步而不是各占一轮。
|
||||
|
||||
### 唤醒有界,且该界不是时间
|
||||
### 连续唤醒上限是可选的,且该界不是时间
|
||||
|
||||
`maxConsecutiveWakes`(默认 3)限制一个所有者由此开启的轮数;超出后通知降级为注入,等待下一轮。领取任何用户撰写的消息都会恢复预算——是领取而非抵达,因为那才是人类输入真正进入某一步的时刻。本插件自己排队的通知永远不会补充它。
|
||||
除非设置了 `maxConsecutiveWakes`,唤醒没有上限。设置后,该值限制一个所有者由此开启的轮数;超出后通知降级为注入,等待下一轮。领取任何用户撰写的消息都会恢复预算——是领取而非抵达,因为那才是人类输入真正进入某一步的时刻。本插件自己排队的通知永远不会补充它。
|
||||
|
||||
设界是因为这条链会自激,而 subagent 结算不会。结算受限于模型派生了多少子 agent;被唤醒的一轮却可能启动某个后台任务,而它的完成又会唤醒同一个所有者,且无人旁观。`dsh run` 不需要单独策略:它唯一的用户消息在第一轮就被领取且不会重复,因此预算单调消耗,进程必然终止。
|
||||
设上限是因为这条链会自激,而 subagent 结算不会。结算受限于模型派生了多少子 agent;被唤醒的一轮却可能启动某个后台任务,而它的完成又会唤醒同一个所有者,且无人旁观。它没有默认值,因为超出上限的通知会让会话不可见地停摆;[默认无上限的决策](2026-09-22-unbounded-completion-wakes-by-default.zh.md)记录了这项取舍。`dsh run` 无论如何都不需要单独策略:headless 在任务轮次空闲后即退出,此后的完成没有可唤醒的所有者。
|
||||
|
||||
`completionDelivery: quiet` 为空闲所有者恢复旧通道。它的存在是为了确定性 transcript;后台任务完成会独立保留 `quiet | wakeup`,因为其有界的所有者轮次策略不同于 next-step subagent 报告。
|
||||
|
||||
@@ -52,15 +52,15 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
|
||||
|
||||
**一个通用的非请求输入队列**并带优先级通道,正如 Claude Code 用来把后台任务、cron、MCP 推送与 hook 合并进同一次排空。DSH 的 inbox 本身就是那个队列——`next-turn`/`next-step` 之上的持久 `agent/inbox/spliced` splice——因此这等于在既有层之上再加一层,只为决定一个 bit。
|
||||
|
||||
**拒绝重开一个已经产出可见答复的轮次**,即 Codex 的 `MailboxDeliveryPhase` 闩锁。那条闩锁正是本决策刻意反转的默认值:在模型已经说完话之后唤醒它就是本特性的全部意义,界由唤醒预算来承担。
|
||||
**拒绝重开一个已经产出可见答复的轮次**,即 Codex 的 `MailboxDeliveryPhase` 闩锁。那条闩锁正是本决策刻意反转的默认值:在模型已经说完话之后唤醒它就是本特性的全部意义,界由可选的唤醒预算来承担。
|
||||
|
||||
**在计数之上再加墙钟窗口**。对交互式 agent 而言,慢的那种情形恰恰是想要的——一小时的构建结束、agent 接着干下去,这就是特性本身——而 `dsh run` 已被它无法补充的计数封顶。只有当出现无人值守的长生命周期部署时才值得重新考虑。
|
||||
**在计数之上再加墙钟窗口**。对交互式 agent 而言,慢的那种情形恰恰是想要的——一小时的构建结束、agent 接着干下去,这就是特性本身——而 `dsh run` 无论如何都在任务轮次空闲后退出。只有当出现无人值守的长生命周期部署时才值得重新考虑。
|
||||
|
||||
**在 owner 排空期间整体压制 `onJobDone`**,与服务级的 `listenersClosed` 对称。它读起来更干净,但会移走一个不只服务于通知的信号:强制失败记录与运行时不变量都会观察 teardown 结算。`reported` 位恰好只否决报告方,别的什么也不否决。
|
||||
|
||||
## 影响
|
||||
|
||||
- 默认行为改变:空闲所有者现在每次完成会花掉一次模型请求,按所有者、在两次用户消息之间由 `maxConsecutiveWakes` 封顶。想要旧行为的部署设置 `completionDelivery: quiet`。
|
||||
- 默认行为改变:空闲所有者现在每次完成会花掉一次模型请求,只有设置了 `maxConsecutiveWakes` 时才按所有者、在两次用户消息之间封顶。想要旧行为的部署设置 `completionDelivery: quiet`。
|
||||
- `tool-jobs` 的提示词段落无需改动;「任务完成时你会在会话内收到通知」从愿景变成了事实。
|
||||
- `JobSnapshot.reported` 新增 teardown 作为第四个置位方,记录在 Service Definition 与[子系统参考](../../../../docs/subsystems/jobs.zh.md)中。
|
||||
- `settle()` 在提交记录并发布可见集变更之后才宣布完成。任何依赖「在释放等待方之前或在 `onJobsChanged` 之前运行」的监听器现在都排在两者之后。
|
||||
@@ -69,7 +69,7 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
|
||||
|
||||
### 已接受的风险
|
||||
|
||||
已花掉的预算只由用户输入恢复。耗尽预算的无人值守 agent 要等到其他原因开启轮次时才收走剩余通知,在此期间没有任何机制为它重新充能。
|
||||
在设置了 `maxConsecutiveWakes` 的情况下,已花掉的预算只由用户输入恢复。耗尽预算的无人值守 agent 要等到其他原因开启轮次时才收走剩余通知,在此期间没有任何机制为它重新充能,通知等待时客户端也毫无显示。这正是上限没有默认值的原因。
|
||||
|
||||
在 `quiet` 下待领于空闲所有者的通知仍会随该所有者释放而消亡,与此前一致:释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。[结算交付 note](2026-08-06-manager-owned-subagent-settlement-delivery.zh.md) 承载这需要的离线信箱讨论。
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-22-unbounded-completion-wakes-by-default.md
|
||||
2026-09-22-unbounded-completion-wakes-by-default.md: 8760154054de423240e16d93e3db5ae01521820a
|
||||
2026-09-22-unbounded-completion-wakes-by-default.zh.md: e5560bc7ced5a8ca9ab55ea56ba309676d78b568
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Background job completion wakes are unbounded by default
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-unbounded-completion-wakes-by-default.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [idle-owner wake decision](2026-08-11-background-job-completion-wakes-an-idle-owner.md) shipped `maxConsecutiveWakes` with a default of 3: an idle owner could be woken three times by job completions before further notices degraded to injection, and only a claimed user message refilled the budget. The counter was per owner and shared by every job kind — background `bash`/`pwsh`/PTY commands and one-shot `subagent_*` children.
|
||||
|
||||
A session that chained more than three of them without a user message hit the cap (issue #4944, reported as [dsh-external/issues#641](https://github.com/dsh-external/issues/issues/641)). The fourth notice was injected into the next-step inbox of an owner nothing would wake; the client renders no pending `context` message, so the session sat with a notice it could not see. The `tool-jobs` prompt promises the model "you are notified in-session when a job finishes — do not busy-poll", which encourages exactly the start-and-end-turn pattern that exhausts the cap. The stall arrived when the user was away and looked like a hang until they typed something.
|
||||
|
||||
## Decision
|
||||
|
||||
`maxConsecutiveWakes` has no default. Unset, every completion reaching an idle owner under `wakeup` delivery opens a turn. The `agent/inbox/claimed` refill listener and the per-owner ledger exist only when a cap is configured; a configured value must still be a whole number of turns, and `Infinity` is rejected because omitting the field already means unbounded.
|
||||
|
||||
The self-exciting chain the cap was built for — a woken turn starting the job whose completion wakes it again — is still real, and deployments that want the bound set the field. The trade-off is that the chain spends model requests the user can watch and stop, while a notice parked past a cap is invisible: nothing in the client shows it, nothing re-arms the budget, and the model's promise becomes false. An observable cost beats an unobservable stall.
|
||||
|
||||
`dsh run` is unaffected: headless exits once the task turn goes idle, so there is no idle owner left to wake.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the default cap and make the parked notice visible** — a session-level pending indicator or an unread mark on the jobs list. This keeps the runaway bound but still breaks the prompt's promise: the model ended its turn expecting a notice and receives nothing until the user acts. Visibility is worth adding for a configured cap; it does not justify a default one.
|
||||
|
||||
**Count only self-excited wakes** — a wake counts against the budget only when the woken turn starts a new background job. This bounds the runaway chain without cutting off a chain of independent jobs, but the distinction needs the plugin to correlate job starts with the turn a wake opened, state no current consumer needs. It remains the natural refinement if a configured cap proves too coarse.
|
||||
|
||||
**A cooldown or rate limit instead of a counter.** Time does not distinguish a wanted chain from a runaway one; the wanted case is often the slow one, and a rate limit still parks a notice invisibly when it triggers.
|
||||
|
||||
**Re-check the next-step inbox on every turn-close path** (abort, LLM error, rejected pre-step). This closes the other parking spots the issue lists, but they are reached only by an already-parked notice; with no default cap the reported scenario never parks one. It stays a separate `agent-loop` change.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An unattended session chaining background jobs is woken for every completion; the model request per wake is the cost, and the user can stop the session.
|
||||
- The cap is opt-in configuration. Its README, config-catalog, and JSDoc state the silent-stall cost of setting it.
|
||||
- Unit coverage pins four unattended completions each opening a turn under the default config; the cap, refill, and quiet-delivery tests keep explicit `maxConsecutiveWakes` values.
|
||||
- No recorded-session snapshot changes: no shipped profile sets the field, and no existing scenario chains more than three idle completions.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Background job completion wakes are unbounded by default
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-unbounded-completion-wakes-by-default.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[空闲所有者唤醒决策](2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)发布时 `maxConsecutiveWakes` 默认为 3:空闲所有者最多被任务完成唤醒三次,此后的通知降级为注入,只有被领取的用户消息才补充预算。计数器按所有者记录,并由所有任务类型共用——后台 `bash`/`pwsh`/PTY 命令与一次性 `subagent_*` 子级。
|
||||
|
||||
一个会话在没有用户消息的情况下串起超过三个这样的任务就会触顶(issue #4944,来源为 [dsh-external/issues#641](https://github.com/dsh-external/issues/issues/641))。第四条通知被注入到一个无人会唤醒的所有者的 next-step inbox;客户端不渲染待领的 `context` 消息,会话就带着一条自己看不见的通知停在那里。`tool-jobs` 的提示词向模型承诺「任务完成会在会话内通知你,不要轮询」,恰恰鼓励了会耗尽上限的「启动后结束本轮」模式。停摆发生在用户离开时,看起来像挂死,直到用户输入点什么。
|
||||
|
||||
## 决策
|
||||
|
||||
`maxConsecutiveWakes` 没有默认值。未设置时,`wakeup` 投递下抵达空闲所有者的每次完成都会开启一轮。`agent/inbox/claimed` 补充监听与按所有者的台账只在配置了上限时存在;配置的值仍必须是整数轮数,`Infinity` 被拒绝,因为省略该字段已经表示无上限。
|
||||
|
||||
上限所针对的自激链——被唤醒的一轮启动某个任务,其完成又唤醒它——依然真实存在,想要该界的部署自行设置字段。取舍在于:这条链花掉的是用户看得见、能停下的模型请求,而停在上限之外的通知是不可见的:客户端不显示,没有机制重新充能预算,模型得到的承诺也成了假话。可观察的代价胜过不可观察的停摆。
|
||||
|
||||
`dsh run` 不受影响:headless 在任务轮次空闲后即退出,没有可唤醒的空闲所有者。
|
||||
|
||||
## 被否决的替代方案
|
||||
|
||||
**保留默认上限,让停驻的通知可见**——会话级的待处理指示或任务列表上的未读标记。这保住了失控约束,但仍然违背提示词的承诺:模型结束本轮等着通知,却要等到用户动手才收到。可见性值得为已配置的上限补上;它不能为默认上限辩护。
|
||||
|
||||
**只统计自激的唤醒**——只有被唤醒的一轮又启动了新的后台任务时才计入预算。这约束了失控链而不切断一串独立任务,但这种区分需要插件把任务启动与唤醒所开启的轮次关联起来,是当前没有消费者需要的状态。若配置的上限被证明过粗,它仍是自然的细化方向。
|
||||
|
||||
**用冷却或速率限制代替计数器。** 时间无法区分想要的链与失控的链;想要的情形往往正是慢的那种,而速率限制触发时同样会把通知不可见地停驻。
|
||||
|
||||
**在每条轮次结束路径上复查 next-step inbox**(abort、LLM 错误、被拒绝的 pre-step)。这关闭了 issue 列出的其他停驻点,但它们只会被一条已停驻的通知触及;没有默认上限时,报告的场景根本不会停驻通知。它仍是独立的 `agent-loop` 改动。
|
||||
|
||||
## 影响
|
||||
|
||||
- 串起后台任务的无人值守会话会为每次完成被唤醒;每次唤醒一次模型请求是其代价,用户可以停止会话。
|
||||
- 上限是可选配置。其 README、config-catalog 与 JSDoc 说明了设置它带来的静默停摆代价。
|
||||
- 单元覆盖钉住默认配置下连续四次无人值守完成各开启一轮;上限、补充与 quiet 投递的测试保留显式的 `maxConsecutiveWakes` 值。
|
||||
- 录制会话快照无变化:没有任何内置 profile 设置该字段,也没有既有场景串起超过三次空闲完成。
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: agent-experience
|
||||
description: Use when designing agent tools, skills, context loading, or multi-step workflows to make information discoverable and use context efficiently.
|
||||
metadata:
|
||||
date: "2026-09-11"
|
||||
---
|
||||
|
||||
- **Start with minimal context:** expose purpose, available actions, and constraints first; load detailed instructions when needed.
|
||||
- **Make discovery explicit:** every deferred resource needs a clear description and a reliable way to retrieve it.
|
||||
- **Keep critical constraints visible:** permissions, destructive effects, and required validation should appear before the relevant action.
|
||||
- **Prefer bounded outputs:** return concise results with identifiers or paths for retrieving details; avoid dumping entire logs or documents.
|
||||
- **Use locality:** include a bounded amount of likely needed adjacent context with an operation’s result. For example, deliver a thread reply with a few preceding messages. Mark omissions and truncation explicitly, and provide a way to retrieve more.
|
||||
- **Evaluate total work:** saving context is useful only if it does not cause more searches, repeated reads, or mistakes.
|
||||
@@ -189,9 +189,9 @@ class TeamFixtureAdapter extends LlmAdapter {
|
||||
}
|
||||
const chunks = identity === 'TEAM_WORKFLOW_CHILD'
|
||||
? textChunks('Fresh workflow child complete.')
|
||||
: identity === '<system-reminder>\nYou are teammate "researcher".\n</system-reminder>'
|
||||
: identity === '<system-reminder>\nYou are teammate "researcher".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "<teammate name>", message: "..." }).\n</system-reminder>'
|
||||
? researcher(options.messages)
|
||||
: identity === '<system-reminder>\nYou are teammate "implementer".\n</system-reminder>'
|
||||
: identity === '<system-reminder>\nYou are teammate "implementer".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "<teammate name>", message: "..." }).\n</system-reminder>'
|
||||
? implementer(options.messages)
|
||||
: lead(options.messages)
|
||||
for (const chunk of chunks) {
|
||||
|
||||
@@ -862,9 +862,17 @@ describe('headless stream-json snapshots', () => {
|
||||
"identityReminders": [
|
||||
"<system-reminder>
|
||||
You are teammate "implementer".
|
||||
Your Team Lead is named "lead".
|
||||
Use list_agents({}) to find your teammates and their names.
|
||||
To message your Team Lead, use send_message({ target: "lead", message: "..." }).
|
||||
To message another teammate, use send_message({ target: "<teammate name>", message: "..." }).
|
||||
</system-reminder>",
|
||||
"<system-reminder>
|
||||
You are teammate "researcher".
|
||||
Your Team Lead is named "lead".
|
||||
Use list_agents({}) to find your teammates and their names.
|
||||
To message your Team Lead, use send_message({ target: "lead", message: "..." }).
|
||||
To message another teammate, use send_message({ target: "<teammate name>", message: "..." }).
|
||||
</system-reminder>",
|
||||
],
|
||||
"memberEdges": 4,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: 59f18ea269f303e267d5e9597d8748e2a8fed134
|
||||
README.zh.md: eb1594e032fea2da38f778e6b2217bd52587af91
|
||||
README.md: df95691f20d434baaeed7f0062989bd28df2b35e
|
||||
README.zh.md: ceefff9cc7d0aaac68f1dd86dcaadad99cfcbdcf
|
||||
@@ -187,14 +187,16 @@ The [Office conversion provider](../../packages/document/office-to-pdf/README.md
|
||||
|
||||
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
|
||||
|
||||
Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
|
||||
Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
|
||||
pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll
|
||||
```
|
||||
|
||||
The Windows installer extracts the new version beside the installation directory, stops the old application, and replaces directories through same-volume renames. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow.
|
||||
|
||||
When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\<package-derived name>-updater\installer-logs\extract-failure-<timestamp>.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness-<version>-win-x64-unsigned.exe` so they cannot be mistaken for release artifacts.
|
||||
|
||||
<a id="upload-updates"></a>
|
||||
|
||||
### Upload updates
|
||||
|
||||
@@ -187,14 +187,16 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安
|
||||
|
||||
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和 Windows 未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。
|
||||
|
||||
Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
|
||||
Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录;通过 `-FrameLibrary` 传入已准备好的 `window-frame.dll`,即可同时覆盖原生解压路径及其失败报告。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
|
||||
pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll
|
||||
```
|
||||
|
||||
Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。
|
||||
|
||||
解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。
|
||||
|
||||
<a id="upload-updates"></a>
|
||||
|
||||
### 上传更新
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AfterPackContext, BeforePackContext } from 'app-builder-lib'
|
||||
/** Electron-builder fields asserted by the Desktop release tests. */
|
||||
export interface DesktopElectronBuilderConfig {
|
||||
readonly appId: string
|
||||
readonly artifactName: string
|
||||
readonly protocols: readonly [{ readonly name: 'DeepSeek Harness'; readonly schemes: readonly ['dsh'] }]
|
||||
readonly directories: {
|
||||
readonly output: string
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// 7-Zip's error output is the primary evidence for an extraction failure; the report preserves it verbatim
|
||||
// on disk and in the clipboard while the dialog shows only a headline and a bounded excerpt.
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <commctrl.h>
|
||||
#include <shlobj.h>
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace extract_report {
|
||||
|
||||
constexpr size_t kOutputLimit = 16 * 1024 * 1024;
|
||||
constexpr size_t kHeadlineLimit = 160;
|
||||
constexpr size_t kExcerptLineLimit = 120;
|
||||
constexpr size_t kExcerptLines = 10;
|
||||
constexpr size_t kExcerptChars = 1000;
|
||||
constexpr wchar_t kEllipsis = 0x2026;
|
||||
constexpr wchar_t kOutputMarker[] = L"7-Zip output:";
|
||||
|
||||
// Exit codes documented by 7-Zip; the installer treats every non-zero value as fatal.
|
||||
inline const wchar_t* SevenZipMeaning(int code) {
|
||||
switch (code) {
|
||||
case 1: return L"warning: some files were not extracted";
|
||||
case 2: return L"fatal error";
|
||||
case 7: return L"command line error";
|
||||
case 8: return L"not enough memory";
|
||||
case 255: return L"stopped before completion";
|
||||
default: return L"unexpected exit code";
|
||||
}
|
||||
}
|
||||
|
||||
inline std::wstring Trim(const std::wstring& text) {
|
||||
const auto begin = text.find_first_not_of(L" \t\r\n");
|
||||
if (begin == std::wstring::npos) return L"";
|
||||
const auto end = text.find_last_not_of(L" \t\r\n");
|
||||
return text.substr(begin, end - begin + 1);
|
||||
}
|
||||
|
||||
inline std::vector<std::wstring> Lines(const std::wstring& text) {
|
||||
std::vector<std::wstring> lines;
|
||||
size_t start = 0;
|
||||
while (start <= text.size()) {
|
||||
const auto end = text.find(L'\n', start);
|
||||
std::wstring line = text.substr(start, end == std::wstring::npos ? std::wstring::npos : end - start);
|
||||
if (!line.empty() && line.back() == L'\r') line.pop_back();
|
||||
lines.push_back(line);
|
||||
if (end == std::wstring::npos) break;
|
||||
start = end + 1;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
inline std::wstring Truncate(const std::wstring& text, size_t limit) {
|
||||
if (text.size() <= limit) return text;
|
||||
return text.substr(0, limit - 1) + kEllipsis;
|
||||
}
|
||||
|
||||
inline std::wstring Win32Message(DWORD error) {
|
||||
LPWSTR buffer = nullptr;
|
||||
const DWORD length = FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||
nullptr, error, 0, reinterpret_cast<LPWSTR>(&buffer), 0, nullptr);
|
||||
std::wstring message = length && buffer ? Trim(std::wstring(buffer, length)) : L"";
|
||||
if (buffer) LocalFree(buffer);
|
||||
return message;
|
||||
}
|
||||
|
||||
// Positive results up to 255 are 7-Zip exit codes and small negative results are negated Win32 errors from launching or
|
||||
// supervising it; anything else is the raw process status (for example an NTSTATUS from a crash), printed in hex.
|
||||
constexpr int kMaxSevenZipExitCode = 255;
|
||||
constexpr int kMaxWin32Error = 0xFFFF;
|
||||
|
||||
inline std::wstring HexStatus(int code) {
|
||||
WCHAR text[16];
|
||||
wsprintfW(text, L"0x%08X", static_cast<unsigned>(code));
|
||||
return text;
|
||||
}
|
||||
|
||||
inline std::wstring DescribeResult(int code) {
|
||||
if (code >= 0 && code <= kMaxSevenZipExitCode) {
|
||||
return L"7-Zip exit code " + std::to_wstring(code) + L" (" + SevenZipMeaning(code) + L")";
|
||||
}
|
||||
if (code < 0 && -static_cast<long long>(code) <= kMaxWin32Error) {
|
||||
const DWORD error = static_cast<DWORD>(-static_cast<long long>(code));
|
||||
std::wstring message = Win32Message(error);
|
||||
return L"Windows error " + std::to_wstring(error) + L" while running 7-Zip" + (message.empty() ? L"" : L" (" + message + L")");
|
||||
}
|
||||
return L"7-Zip terminated with status " + HexStatus(code);
|
||||
}
|
||||
|
||||
inline bool EqualsIgnoreCase(const std::wstring& left, const std::wstring& right) {
|
||||
return left.size() == right.size() && _wcsnicmp(left.c_str(), right.c_str(), left.size()) == 0;
|
||||
}
|
||||
|
||||
// 7-Zip reports failures as "<operation> ERROR: <reason>" or "ERROR: <operation> : <reason> : <path>"; it also echoes the
|
||||
// bare archive path after "ERROR:" before an open failure, which names nothing and is skipped.
|
||||
inline std::wstring Headline(int code, const std::wstring& output, const std::wstring& archive) {
|
||||
const auto separator = archive.find_last_of(L"\\/");
|
||||
const std::wstring archiveName = separator == std::wstring::npos ? archive : archive.substr(separator + 1);
|
||||
std::wstring fallback;
|
||||
for (const std::wstring& raw : Lines(output)) {
|
||||
const std::wstring line = Trim(raw);
|
||||
const auto marker = line.find(L"ERROR");
|
||||
if (marker == std::wstring::npos) continue;
|
||||
const auto colon = line.find(L':', marker);
|
||||
const std::wstring detail = colon == std::wstring::npos ? L"" : Trim(line.substr(colon + 1));
|
||||
if (detail.empty() || EqualsIgnoreCase(detail, archive) || EqualsIgnoreCase(detail, archiveName)) continue;
|
||||
if (detail.find(L" : ") != std::wstring::npos) return Truncate(line, kHeadlineLimit);
|
||||
if (fallback.empty()) fallback = line;
|
||||
}
|
||||
if (!fallback.empty()) return Truncate(fallback, kHeadlineLimit);
|
||||
return DescribeResult(code);
|
||||
}
|
||||
|
||||
inline std::wstring Compose(int code, const std::wstring& archive, const std::wstring& destination, const std::wstring& output,
|
||||
const std::wstring& timestamp, const std::wstring& windowsVersion) {
|
||||
std::wstring report = L"DeepSeek Harness installer: extraction failed\r\n";
|
||||
report += L"Time: " + timestamp + L"\r\n";
|
||||
report += L"Result: " + DescribeResult(code) + L"\r\n";
|
||||
report += L"Archive: " + archive + L"\r\n";
|
||||
report += L"Destination: " + destination + L"\r\n";
|
||||
report += L"Windows: " + windowsVersion + L"\r\n";
|
||||
report += L"\r\n" + std::wstring(kOutputMarker) + L"\r\n";
|
||||
const std::wstring trimmed = Trim(output);
|
||||
if (trimmed.empty()) {
|
||||
report += L"(none)\r\n";
|
||||
} else {
|
||||
for (const std::wstring& line : Lines(trimmed)) report += line + L"\r\n";
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
// The expanded panel adds 7-Zip's own lines beneath the result; long lines are cut and a trailing note counts what the
|
||||
// saved report still holds. Header fields already appear in the report file and clipboard.
|
||||
inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) {
|
||||
std::vector<std::wstring> lines;
|
||||
bool inOutput = false;
|
||||
for (const std::wstring& line : Lines(Trim(report))) {
|
||||
if (inOutput) lines.push_back(line);
|
||||
else if (line.rfind(L"Result: ", 0) == 0) lines.push_back(line);
|
||||
else if (line == kOutputMarker) inOutput = true;
|
||||
}
|
||||
std::wstring excerpt;
|
||||
size_t shown = 0;
|
||||
for (const std::wstring& line : lines) {
|
||||
const std::wstring cut = Truncate(line, kExcerptLineLimit);
|
||||
if (shown == maxLines || excerpt.size() + cut.size() + 2 > maxChars) break;
|
||||
excerpt += (shown ? L"\r\n" : L"") + cut;
|
||||
++shown;
|
||||
}
|
||||
if (shown < lines.size()) {
|
||||
excerpt += L"\r\n" + std::wstring(1, kEllipsis) + L" (" + std::to_wstring(lines.size() - shown) + L" more lines in the saved report)";
|
||||
}
|
||||
return excerpt;
|
||||
}
|
||||
|
||||
// A cut inside a multi-byte sequence would make the whole UTF-8 attempt fail; drop the incomplete tail first.
|
||||
inline void TrimPartialUtf8(std::string& bytes) {
|
||||
size_t trailing = 0;
|
||||
while (trailing < 3 && trailing < bytes.size() && (static_cast<unsigned char>(bytes[bytes.size() - 1 - trailing]) & 0xC0) == 0x80) ++trailing;
|
||||
if (trailing >= bytes.size()) return;
|
||||
const unsigned char lead = static_cast<unsigned char>(bytes[bytes.size() - 1 - trailing]);
|
||||
const size_t expected = lead >= 0xF0 ? 4 : lead >= 0xE0 ? 3 : lead >= 0xC0 ? 2 : 1;
|
||||
if (expected > trailing + 1) bytes.resize(bytes.size() - 1 - trailing);
|
||||
}
|
||||
|
||||
inline std::wstring Decode(const std::string& bytes) {
|
||||
if (bytes.empty()) return L"";
|
||||
for (UINT codePage : {static_cast<UINT>(CP_UTF8), static_cast<UINT>(CP_ACP)}) {
|
||||
const DWORD flags = codePage == CP_UTF8 ? MB_ERR_INVALID_CHARS : 0;
|
||||
const int length = MultiByteToWideChar(codePage, flags, bytes.data(), static_cast<int>(bytes.size()), nullptr, 0);
|
||||
if (length <= 0) continue;
|
||||
std::wstring text(static_cast<size_t>(length), L'\0');
|
||||
MultiByteToWideChar(codePage, flags, bytes.data(), static_cast<int>(bytes.size()), &text[0], length);
|
||||
return text;
|
||||
}
|
||||
return L"(undecodable 7-Zip output)";
|
||||
}
|
||||
|
||||
// The saved report keeps everything 7-Zip wrote; only pathological output beyond kOutputLimit is cut, and the cut is recorded.
|
||||
inline std::wstring ReadOutput(LPCWSTR path) {
|
||||
HANDLE file = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) return L"";
|
||||
std::string bytes;
|
||||
char buffer[8192];
|
||||
DWORD count = 0;
|
||||
bool truncated = false;
|
||||
while (ReadFile(file, buffer, sizeof(buffer), &count, nullptr) && count) {
|
||||
if (bytes.size() + count > kOutputLimit) {
|
||||
bytes.append(buffer, kOutputLimit - bytes.size());
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
bytes.append(buffer, count);
|
||||
}
|
||||
CloseHandle(file);
|
||||
if (truncated) TrimPartialUtf8(bytes);
|
||||
std::wstring output = Decode(bytes);
|
||||
if (truncated) output += L"\r\n[7-Zip output truncated after " + std::to_wstring(kOutputLimit / (1024 * 1024)) + L" MiB]\r\n";
|
||||
return output;
|
||||
}
|
||||
|
||||
inline std::wstring Timestamp() {
|
||||
SYSTEMTIME now;
|
||||
GetLocalTime(&now);
|
||||
WCHAR text[32];
|
||||
wsprintfW(text, L"%04u-%02u-%02u %02u:%02u:%02u", now.wYear, now.wMonth, now.wDay, now.wHour, now.wMinute, now.wSecond);
|
||||
return text;
|
||||
}
|
||||
|
||||
inline std::wstring WindowsVersion() {
|
||||
typedef LONG(WINAPI * RtlGetVersionFn)(PRTL_OSVERSIONINFOW);
|
||||
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
|
||||
auto getVersion = ntdll ? reinterpret_cast<RtlGetVersionFn>(GetProcAddress(ntdll, "RtlGetVersion")) : nullptr;
|
||||
RTL_OSVERSIONINFOW info = {};
|
||||
info.dwOSVersionInfoSize = sizeof(info);
|
||||
if (!getVersion || getVersion(&info) != 0) return L"unknown";
|
||||
WCHAR text[64];
|
||||
wsprintfW(text, L"%u.%u.%u", info.dwMajorVersion, info.dwMinorVersion, info.dwBuildNumber);
|
||||
return text;
|
||||
}
|
||||
|
||||
inline bool WriteUtf8(LPCWSTR path, const std::wstring& text) {
|
||||
std::wstring directory(path);
|
||||
const auto separator = directory.find_last_of(L"\\/");
|
||||
if (separator != std::wstring::npos) {
|
||||
directory.resize(separator);
|
||||
const int created = SHCreateDirectoryExW(nullptr, directory.c_str(), nullptr);
|
||||
if (created != ERROR_SUCCESS && created != ERROR_ALREADY_EXISTS && created != ERROR_FILE_EXISTS) return false;
|
||||
}
|
||||
const int length = WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0, nullptr, nullptr);
|
||||
if (length <= 0) return false;
|
||||
std::string bytes("\xEF\xBB\xBF", 3);
|
||||
bytes.resize(3 + static_cast<size_t>(length));
|
||||
WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), &bytes[3], length, nullptr, nullptr);
|
||||
HANDLE file = CreateFileW(path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) return false;
|
||||
DWORD written = 0;
|
||||
const bool complete = WriteFile(file, bytes.data(), static_cast<DWORD>(bytes.size()), &written, nullptr) && written == bytes.size();
|
||||
CloseHandle(file);
|
||||
return complete;
|
||||
}
|
||||
|
||||
inline bool CopyToClipboard(HWND owner, const std::wstring& text) {
|
||||
const size_t bytes = (text.size() + 1) * sizeof(wchar_t);
|
||||
HGLOBAL memory = GlobalAlloc(GMEM_MOVEABLE, bytes);
|
||||
if (!memory) return false;
|
||||
void* target = GlobalLock(memory);
|
||||
if (!target) { GlobalFree(memory); return false; }
|
||||
memcpy(target, text.c_str(), bytes);
|
||||
GlobalUnlock(memory);
|
||||
bool copied = false;
|
||||
for (int attempt = 0; attempt < 5 && !copied; ++attempt) {
|
||||
if (attempt) Sleep(50);
|
||||
if (!OpenClipboard(owner)) continue;
|
||||
copied = EmptyClipboard() && SetClipboardData(CF_UNICODETEXT, memory) != nullptr;
|
||||
CloseClipboard();
|
||||
}
|
||||
if (!copied) GlobalFree(memory);
|
||||
return copied;
|
||||
}
|
||||
|
||||
struct DialogStrings {
|
||||
LPCWSTR title;
|
||||
LPCWSTR heading;
|
||||
LPCWSTR copy;
|
||||
LPCWSTR expand;
|
||||
LPCWSTR collapse;
|
||||
LPCWSTR footer;
|
||||
LPCWSTR copied;
|
||||
};
|
||||
|
||||
struct DialogState {
|
||||
const std::wstring* report;
|
||||
const DialogStrings* strings;
|
||||
};
|
||||
|
||||
constexpr int kCopyButton = 1001;
|
||||
|
||||
inline HRESULT CALLBACK DialogCallback(HWND dialog, UINT notification, WPARAM wparam, LPARAM, LONG_PTR data) {
|
||||
auto* state = reinterpret_cast<DialogState*>(data);
|
||||
if (notification == TDN_BUTTON_CLICKED && static_cast<int>(wparam) == kCopyButton && state) {
|
||||
if (CopyToClipboard(dialog, *state->report)) {
|
||||
SendMessageW(dialog, TDM_SET_ELEMENT_TEXT, TDE_FOOTER, reinterpret_cast<LPARAM>(state->strings->copied));
|
||||
}
|
||||
return S_FALSE;
|
||||
}
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
// The task dialog comes from the comctl32 v6 the installer already loads; without it a message box carries the headline.
|
||||
inline void Show(HWND parent, const std::wstring& headline, const std::wstring& excerpt, const std::wstring& report, const DialogStrings& strings) {
|
||||
typedef HRESULT(WINAPI * TaskDialogIndirectFn)(const TASKDIALOGCONFIG*, int*, int*, BOOL*);
|
||||
HMODULE comctl = GetModuleHandleW(L"comctl32.dll");
|
||||
if (!comctl) comctl = LoadLibraryW(L"comctl32.dll");
|
||||
auto taskDialog = comctl ? reinterpret_cast<TaskDialogIndirectFn>(GetProcAddress(comctl, "TaskDialogIndirect")) : nullptr;
|
||||
if (taskDialog) {
|
||||
DialogState state{&report, &strings};
|
||||
TASKDIALOG_BUTTON buttons[] = {{kCopyButton, strings.copy}};
|
||||
TASKDIALOGCONFIG config = {};
|
||||
config.cbSize = sizeof(config);
|
||||
config.hwndParent = parent;
|
||||
config.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION | TDF_POSITION_RELATIVE_TO_WINDOW;
|
||||
config.dwCommonButtons = TDCBF_OK_BUTTON;
|
||||
config.pszWindowTitle = strings.title;
|
||||
config.pszMainIcon = TD_WARNING_ICON;
|
||||
config.pszMainInstruction = strings.heading;
|
||||
config.pszContent = headline.c_str();
|
||||
config.cButtons = 1;
|
||||
config.pButtons = buttons;
|
||||
config.nDefaultButton = IDOK;
|
||||
config.pszExpandedInformation = excerpt.c_str();
|
||||
config.pszExpandedControlText = strings.collapse;
|
||||
config.pszCollapsedControlText = strings.expand;
|
||||
config.pszFooterIcon = TD_INFORMATION_ICON;
|
||||
config.pszFooter = strings.footer;
|
||||
config.pfCallback = DialogCallback;
|
||||
config.lpCallbackData = reinterpret_cast<LONG_PTR>(&state);
|
||||
config.cxWidth = 320;
|
||||
int pressed = 0;
|
||||
if (SUCCEEDED(taskDialog(&config, &pressed, nullptr, nullptr))) return;
|
||||
}
|
||||
const std::wstring text = std::wstring(strings.heading) + L"\r\n\r\n" + headline + L"\r\n\r\n" + strings.footer;
|
||||
MessageBoxW(parent, text.c_str(), strings.title, MB_OK | MB_ICONEXCLAMATION | MB_SETFOREGROUND);
|
||||
}
|
||||
|
||||
} // namespace extract_report
|
||||
@@ -32,25 +32,30 @@ struct InstallerAttributes {
|
||||
}
|
||||
};
|
||||
|
||||
static DWORD ExtractApplication(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
|
||||
// Win32 failures around the 7-Zip process return negated error codes so they never collide with 7-Zip's exit codes.
|
||||
static int LaunchFailure(DWORD error) {
|
||||
return -static_cast<int>(error ? error : ERROR_GEN_FAILURE);
|
||||
}
|
||||
|
||||
static int ExtractApplication(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
|
||||
SECURITY_ATTRIBUTES security = {sizeof(security), nullptr, TRUE};
|
||||
InstallerHandle input, output;
|
||||
if (!CreatePipe(&input.value, &output.value, &security, 0)) return GetLastError();
|
||||
if (!SetHandleInformation(input.value, HANDLE_FLAG_INHERIT, 0)) return GetLastError();
|
||||
if (!CreatePipe(&input.value, &output.value, &security, 0)) return LaunchFailure(GetLastError());
|
||||
if (!SetHandleInformation(input.value, HANDLE_FLAG_INHERIT, 0)) return LaunchFailure(GetLastError());
|
||||
InstallerHandle errors(CreateFileW(log, GENERIC_WRITE, FILE_SHARE_READ, &security, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr));
|
||||
if (errors.value == INVALID_HANDLE_VALUE) return GetLastError();
|
||||
if (errors.value == INVALID_HANDLE_VALUE) return LaunchFailure(GetLastError());
|
||||
InstallerHandle nullInput(CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &security, OPEN_EXISTING, 0, nullptr));
|
||||
if (nullInput.value == INVALID_HANDLE_VALUE) return GetLastError();
|
||||
if (nullInput.value == INVALID_HANDLE_VALUE) return LaunchFailure(GetLastError());
|
||||
InstallerHandle job(CreateJobObjectW(nullptr, nullptr));
|
||||
if (!job.value) return GetLastError();
|
||||
if (!job.value) return LaunchFailure(GetLastError());
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {};
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
if (!SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) return GetLastError();
|
||||
if (!SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) return LaunchFailure(GetLastError());
|
||||
InstallerAttributes attributes;
|
||||
if (!attributes.Initialize()) return GetLastError();
|
||||
if (!attributes.Initialize()) return LaunchFailure(GetLastError());
|
||||
HANDLE inherited[] = {nullInput.value, output.value, errors.value};
|
||||
if (!UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, sizeof(inherited), nullptr, nullptr) ||
|
||||
!UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST, &job.value, sizeof(job.value), nullptr, nullptr)) return GetLastError();
|
||||
!UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST, &job.value, sizeof(job.value), nullptr, nullptr)) return LaunchFailure(GetLastError());
|
||||
STARTUPINFOEXW startup = {};
|
||||
startup.StartupInfo.cb = sizeof(startup);
|
||||
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
|
||||
@@ -59,10 +64,11 @@ static DWORD ExtractApplication(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCW
|
||||
startup.StartupInfo.hStdError = errors.value;
|
||||
startup.lpAttributeList = attributes.list;
|
||||
// A final dot prevents a destination's trailing backslash from escaping its closing quote.
|
||||
std::wstring command = L"\"" + std::wstring(tool) + L"\" x -y -bso0 -bse2 -bsp1 -bb0 \"-o" + destination + L"\\.\" \"" + archive + L"\"";
|
||||
// Error output is UTF-8 so the failure report can quote paths from any code page.
|
||||
std::wstring command = L"\"" + std::wstring(tool) + L"\" x -y -bso0 -bse2 -bsp1 -bb0 -sccUTF-8 \"-o" + destination + L"\\.\" \"" + archive + L"\"";
|
||||
PROCESS_INFORMATION child = {};
|
||||
if (!CreateProcessW(tool, &command[0], nullptr, nullptr, TRUE,
|
||||
EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW, nullptr, nullptr, &startup.StartupInfo, &child)) return GetLastError();
|
||||
EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW, nullptr, nullptr, &startup.StartupInfo, &child)) return LaunchFailure(GetLastError());
|
||||
InstallerHandle process(child.hProcess), thread(child.hThread);
|
||||
output.Close();
|
||||
errors.Close();
|
||||
@@ -79,17 +85,20 @@ static DWORD ExtractApplication(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCW
|
||||
if (readError != ERROR_BROKEN_PIPE && readError != ERROR_SUCCESS) {
|
||||
TerminateJobObject(job.value, readError);
|
||||
WaitForSingleObject(process.value, INFINITE);
|
||||
return readError;
|
||||
return LaunchFailure(readError);
|
||||
}
|
||||
if (WaitForSingleObject(process.value, INFINITE) != WAIT_OBJECT_0) return GetLastError();
|
||||
if (WaitForSingleObject(process.value, INFINITE) != WAIT_OBJECT_0) return LaunchFailure(GetLastError());
|
||||
DWORD result;
|
||||
if (!GetExitCodeProcess(process.value, &result)) return GetLastError();
|
||||
if (!GetExitCodeProcess(process.value, &result)) return LaunchFailure(GetLastError());
|
||||
if (result == 0 && parent) SetPropW(parent, L"HarnessInstaller.ExtractProgress", reinterpret_cast<HANDLE>(100));
|
||||
return result;
|
||||
// A crash status such as 0xC0000005 comes back bit-for-bit; the report prints anything beyond 7-Zip's exit codes in hex.
|
||||
return static_cast<int>(result);
|
||||
}
|
||||
|
||||
// Runs on the NSIS worker; only an exit code of zero permits directory promotion.
|
||||
extern "C" __declspec(dllexport) DWORD __cdecl InstallerExtract(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
|
||||
// Results 0-255 are 7-Zip exit codes, small negative results are negated Win32 errors from launching or supervising it,
|
||||
// and any other value is the raw process status.
|
||||
extern "C" __declspec(dllexport) int __cdecl InstallerExtract(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
|
||||
try { return ExtractApplication(parent, tool, archive, destination, log); }
|
||||
catch (const std::bad_alloc&) { return ERROR_NOT_ENOUGH_MEMORY; }
|
||||
catch (const std::bad_alloc&) { return LaunchFailure(ERROR_NOT_ENOUGH_MEMORY); }
|
||||
}
|
||||
@@ -39,3 +39,19 @@ LangString INSTALLER_DISK_SPACE ${LANG_ENGLISH} "There is not enough free disk s
|
||||
LangString INSTALLER_DISK_SPACE ${LANG_SIMPCHINESE} "此磁盘的可用空间不足,请选择其他位置。"
|
||||
LangString INSTALLER_PER_USER ${LANG_ENGLISH} "This installer supports the current user only. Uninstall the existing all-users installation first."
|
||||
LangString INSTALLER_PER_USER ${LANG_SIMPCHINESE} "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。"
|
||||
LangString INSTALLER_EXTRACT_FAILED ${LANG_ENGLISH} "The application files could not be extracted."
|
||||
LangString INSTALLER_EXTRACT_FAILED ${LANG_SIMPCHINESE} "无法解压应用程序文件。"
|
||||
LangString INSTALLER_EXTRACT_HINT ${LANG_ENGLISH} "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below."
|
||||
LangString INSTALLER_EXTRACT_HINT ${LANG_SIMPCHINESE} "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。"
|
||||
LangString INSTALLER_EXTRACT_COPY ${LANG_ENGLISH} "Copy error details"
|
||||
LangString INSTALLER_EXTRACT_COPY ${LANG_SIMPCHINESE} "复制错误信息"
|
||||
LangString INSTALLER_EXTRACT_EXPAND ${LANG_ENGLISH} "Show details"
|
||||
LangString INSTALLER_EXTRACT_EXPAND ${LANG_SIMPCHINESE} "显示详情"
|
||||
LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_ENGLISH} "Hide details"
|
||||
LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_SIMPCHINESE} "隐藏详情"
|
||||
LangString INSTALLER_EXTRACT_SAVED ${LANG_ENGLISH} "The full report was saved to %s"
|
||||
LangString INSTALLER_EXTRACT_SAVED ${LANG_SIMPCHINESE} "完整错误报告已保存到 %s"
|
||||
LangString INSTALLER_EXTRACT_UNSAVED ${LANG_ENGLISH} "The full report could not be saved. Use Copy error details to keep it."
|
||||
LangString INSTALLER_EXTRACT_UNSAVED ${LANG_SIMPCHINESE} "无法保存完整错误报告,请使用“复制错误信息”保留。"
|
||||
LangString INSTALLER_EXTRACT_COPIED ${LANG_ENGLISH} "Error details were copied to the clipboard."
|
||||
LangString INSTALLER_EXTRACT_COPIED ${LANG_SIMPCHINESE} "错误信息已复制到剪贴板。"
|
||||
@@ -12,10 +12,35 @@
|
||||
#include <algorithm>
|
||||
#include "progress.h"
|
||||
#include "extract.h"
|
||||
#include "extract-report.h"
|
||||
#include "uninstall-data.h"
|
||||
|
||||
using namespace Gdiplus;
|
||||
|
||||
// Saves the extraction failure report and, unless silent, presents its headline with a copy action.
|
||||
// Returns 1 when the report file was written and 0 otherwise; the dialog outcome never fails the caller.
|
||||
extern "C" __declspec(dllexport) int __cdecl InstallerReportExtractFailure(
|
||||
HWND parent, int code, LPCWSTR archive, LPCWSTR destination, LPCWSTR log, LPCWSTR reportPath, int show,
|
||||
LPCWSTR title, LPCWSTR heading, LPCWSTR hint, LPCWSTR copy, LPCWSTR expand, LPCWSTR collapse, LPCWSTR savedFormat, LPCWSTR unsaved, LPCWSTR copied) {
|
||||
try {
|
||||
const std::wstring output = extract_report::ReadOutput(log);
|
||||
const std::wstring report = extract_report::Compose(code, archive, destination, output,
|
||||
extract_report::Timestamp(), extract_report::WindowsVersion());
|
||||
const bool saved = extract_report::WriteUtf8(reportPath, report);
|
||||
if (show) {
|
||||
std::wstring footer = saved ? savedFormat : unsaved;
|
||||
const auto placeholder = footer.find(L"%s");
|
||||
if (saved && placeholder != std::wstring::npos) footer.replace(placeholder, 2, reportPath);
|
||||
const extract_report::DialogStrings strings{title, heading, copy, expand, collapse, footer.c_str(), copied};
|
||||
const std::wstring content = extract_report::Headline(code, output, archive) + L"\r\n\r\n" + hint;
|
||||
extract_report::Show(parent, content, extract_report::Excerpt(report), report, strings);
|
||||
}
|
||||
return saved ? 1 : 0;
|
||||
} catch (const std::bad_alloc&) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a Win32 error code; no cleanup request may traverse a linked root or touch the protected root.
|
||||
extern "C" __declspec(dllexport) DWORD __cdecl UninstallRemoveData(LPCWSTR path, LPCWSTR installation, LPCWSTR protectedRoot) {
|
||||
return uninstall_data::Remove(path, installation, protectedRoot);
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"electron-updater": "^6.8.9",
|
||||
"semver": "^7.8.5",
|
||||
"ws": "^8.21.0",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^"
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
|
||||
@@ -30,8 +30,8 @@ const LISTING_DEADLINE_MS = 8_000
|
||||
/** Objects one listing page may return. */
|
||||
const LISTING_PAGE_SIZE = 1000
|
||||
|
||||
/** Artifact name electron-builder writes for one build, on either platform. */
|
||||
const ARTIFACT = /(?:^|\/)deepseek-harness-(?<version>.+)-(?:mac|win)-(?:arm64|x64)\.(?:exe|dmg|zip)$/u
|
||||
/** Artifact name electron-builder writes for one build, on either platform; unsigned Windows builds add a suffix. */
|
||||
const ARTIFACT = /(?:^|\/)deepseek-harness-(?<version>.+)-(?:mac|win)-(?:arm64|x64)(?:-unsigned)?\.(?:exe|dmg|zip)$/u
|
||||
|
||||
/** Inputs that decide which versions are already taken. */
|
||||
export interface DesktopBuildVersionSuggestionOptions {
|
||||
|
||||
@@ -106,7 +106,8 @@ export function createElectronBuilderConfig(
|
||||
...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty },
|
||||
},
|
||||
productName: 'DeepSeek Harness',
|
||||
artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}',
|
||||
// Unsigned builds carry their own suffix so a shared file can never pass for a release artifact.
|
||||
artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${unsigned ? '-unsigned' : ''}.\${ext}`,
|
||||
directories: { output: unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts },
|
||||
asar: true,
|
||||
electronDist: buildPaths.electron,
|
||||
|
||||
@@ -17,7 +17,11 @@ Var dshNewMoved
|
||||
${If} $R0 != 0
|
||||
DetailPrint $R1
|
||||
Call dshRollbackDirectories
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "$(decompressionFailed)" /SD IDOK
|
||||
!ifmacrodef customInstallerExtractFailed
|
||||
!insertmacro customInstallerExtractFailed "${FILE}"
|
||||
!else
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "$(decompressionFailed)" /SD IDOK
|
||||
!endif
|
||||
SetErrorLevel 2
|
||||
Quit
|
||||
${EndIf}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
!include "LogicLib.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
!define INSTALLER_SOURCE_DIR "${__FILEDIR__}\..\installer"
|
||||
!define /ifndef INSTALLER_BUILD_DIR "${__FILEDIR__}\..\.desktop-build\targets\win-x64\installer-ui"
|
||||
|
||||
@@ -120,16 +121,43 @@ ManifestDPIAware true
|
||||
System::Call /NOUNLOAD '$PLUGINSDIR\window-frame.dll::InstallerExtract(p $HWNDPARENT, w "$PLUGINSDIR\dsh-7za.exe", w "${Archive}", w "$INSTDIR", w "$PLUGINSDIR\extract.log") i.s ?c'
|
||||
System::Store "L"
|
||||
Pop $R0
|
||||
; The failure report owns 7-Zip's UTF-8 output; the details view only needs the result code.
|
||||
StrCpy $R1 "$R0"
|
||||
${If} $R0 != 0
|
||||
Push $0
|
||||
FileOpen $0 "$PLUGINSDIR\extract.log" r
|
||||
${IfNot} ${Errors}
|
||||
FileRead $0 $R1
|
||||
FileClose $0
|
||||
${EndIf}
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
; The report outlives $PLUGINSDIR so a user can send it; silent installs keep only the file. The updater cache
|
||||
; directory is never an installation target and leaves with the application on uninstall.
|
||||
; The directory smoke fixture predefines DSH_INSTALLER_LOG_DIR to keep reports inside its scratch tree.
|
||||
!ifndef DSH_INSTALLER_LOG_DIR
|
||||
!define DSH_INSTALLER_LOG_DIR "$LOCALAPPDATA\${DSH_UPDATER_CACHE_NAME}\installer-logs"
|
||||
!endif
|
||||
|
||||
!macro customInstallerExtractFailed Archive
|
||||
Push $0
|
||||
Push $1
|
||||
Push $2
|
||||
Push $3
|
||||
Push $4
|
||||
Push $5
|
||||
Push $6
|
||||
; GetTime yields zero-padded day, month, year, weekday, hour, minute, second.
|
||||
${GetTime} "" "L" $0 $1 $2 $3 $4 $5 $6
|
||||
StrCpy $0 "${DSH_INSTALLER_LOG_DIR}\extract-failure-$2$1$0-$4$5$6.log"
|
||||
StrCpy $1 1
|
||||
${If} ${Silent}
|
||||
StrCpy $1 0
|
||||
${EndIf}
|
||||
System::Call '$PLUGINSDIR\window-frame.dll::InstallerReportExtractFailure(p $HWNDPARENT, i R0, w "${Archive}", w "$dshNewDirectory", w "$PLUGINSDIR\extract.log", w r0, i r1, w "$(^SetupCaption)", w "$(INSTALLER_EXTRACT_FAILED)", w "$(INSTALLER_EXTRACT_HINT)", w "$(INSTALLER_EXTRACT_COPY)", w "$(INSTALLER_EXTRACT_EXPAND)", w "$(INSTALLER_EXTRACT_COLLAPSE)", w "$(INSTALLER_EXTRACT_SAVED)", w "$(INSTALLER_EXTRACT_UNSAVED)", w "$(INSTALLER_EXTRACT_COPIED)") i.r2 ?c'
|
||||
${If} $2 == 1
|
||||
DetailPrint $0
|
||||
${EndIf}
|
||||
Pop $6
|
||||
Pop $5
|
||||
Pop $4
|
||||
Pop $3
|
||||
Pop $2
|
||||
Pop $1
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customCheckAppRunning
|
||||
|
||||
@@ -50,6 +50,16 @@ if ($TestProgress -or $CompileProgressOnly) {
|
||||
& $presentationExecutable
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Installer presentation regression failed.' }
|
||||
}
|
||||
$reportSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-extract-report.cpp'))
|
||||
$reportExecutable = Join-Path $output 'extract-report-test.exe'
|
||||
$reportScript = Join-Path $output 'compile-extract-report-test.cmd'
|
||||
[IO.File]::WriteAllLines($reportScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link "{3}" user32.lib shell32.lib' -f $reportSource, (Join-Path $output 'extract-report-test.obj'), $reportExecutable, (Join-Path $output 'window-frame.lib'))), [Text.Encoding]::Default)
|
||||
& $env:ComSpec /d /c $reportScript
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Extraction report test compilation failed.' }
|
||||
if ($TestProgress) {
|
||||
& $reportExecutable
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Extraction report regression failed.' }
|
||||
}
|
||||
}
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
foreach ($asset in @('brand', 'brand-2x', 'brand-dark', 'brand-dark-2x', 'uninstaller-sidebar')) {
|
||||
|
||||
@@ -8,6 +8,7 @@ param(
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Makensis = [System.IO.Path]::GetFullPath($Makensis)
|
||||
$SevenZip = [System.IO.Path]::GetFullPath($SevenZip)
|
||||
if ($FrameLibrary) { $FrameLibrary = [System.IO.Path]::GetFullPath($FrameLibrary) }
|
||||
$scratch = [System.IO.Directory]::CreateTempSubdirectory('dsh-directory-smoke-').FullName
|
||||
$fixture = Join-Path $PSScriptRoot '../tests/fixtures/installer-directory-smoke.nsi'
|
||||
|
||||
@@ -42,8 +43,9 @@ try {
|
||||
}
|
||||
$probe = Join-Path $caseRoot 'probe.exe'
|
||||
$caseArchive = if ($mode -eq 'broken') { $brokenArchive } else { $archive }
|
||||
$reportDir = Join-Path $caseRoot 'installer-logs'
|
||||
$compileArgs = @('/V2', "/DOUTPUT_FILE=$probe", "/DPAYLOAD_FILE=$caseArchive", "/DTARGET_DIR=$target", "/DDSH_SEVENZIP_PATH=$SevenZip")
|
||||
if ($FrameLibrary) { $compileArgs += "/DSOURCE_DLL=$FrameLibrary" }
|
||||
if ($FrameLibrary) { $compileArgs += @("/DSOURCE_DLL=$FrameLibrary", "/DREPORT_DIR=$reportDir") }
|
||||
if ($mode -eq 'missing-stage') { $compileArgs += '/DMISSING_STAGE' }
|
||||
if ($mode -eq 'cancelled') { $compileArgs += '/DCANCELLED' }
|
||||
Invoke-Checked $Makensis ($compileArgs + $fixture)
|
||||
@@ -76,9 +78,24 @@ try {
|
||||
if ((Test-Path -LiteralPath (Join-Path $target 'obsolete.txt')) -ne $expectObsolete) {
|
||||
throw "Directory smoke $mode did not preserve the expected old-only file state"
|
||||
}
|
||||
if (@(Get-ChildItem -LiteralPath $caseRoot -Directory | Where-Object Name -ne 'Application').Count -ne 0) {
|
||||
if (@(Get-ChildItem -LiteralPath $caseRoot -Directory | Where-Object Name -notin @('Application', 'installer-logs')).Count -ne 0) {
|
||||
throw "Directory smoke $mode left transaction directories behind"
|
||||
}
|
||||
if ($FrameLibrary) {
|
||||
# Only an extraction failure writes a report; it must quote 7-Zip's verdict so support can read the cause.
|
||||
$reports = @(if (Test-Path -LiteralPath $reportDir) { Get-ChildItem -LiteralPath $reportDir -File })
|
||||
if ($mode -eq 'broken') {
|
||||
if ($reports.Count -ne 1 -or $reports[0].Name -notmatch '^extract-failure-20\d{6}-\d{6}\.log$') {
|
||||
throw "Directory smoke $mode did not save exactly one extraction report"
|
||||
}
|
||||
$report = Get-Content -LiteralPath $reports[0].FullName -Raw -Encoding UTF8
|
||||
foreach ($expected in @('Result: 7-Zip exit code 2 (fatal error)', "Archive: $caseArchive", 'Cannot open the file as [7z] archive')) {
|
||||
if (-not $report.Contains($expected)) { throw "Directory smoke $mode report lacks '$expected'" }
|
||||
}
|
||||
} elseif ($reports.Count -ne 0) {
|
||||
throw "Directory smoke $mode saved an unexpected extraction report"
|
||||
}
|
||||
}
|
||||
Write-Output "Directory replacement smoke passed: $mode"
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Makensis,
|
||||
[Parameter(Mandatory)][string]$SevenZip,
|
||||
[Parameter(Mandatory)][string]$PluginDir
|
||||
[Parameter(Mandatory)][string]$PluginDir,
|
||||
[string]$FrameLibrary
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$desktopRoot = Split-Path $PSScriptRoot -Parent
|
||||
@@ -15,7 +16,10 @@ function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
|
||||
}
|
||||
|
||||
try {
|
||||
& (Join-Path $PSScriptRoot 'smoke-installer-directories.ps1') -Makensis $Makensis -SevenZip $SevenZip
|
||||
# The prepared window-frame.dll adds the native extraction path and its failure report to the directory checks.
|
||||
$directoryArguments = @{ Makensis = $Makensis; SevenZip = $SevenZip }
|
||||
if ($FrameLibrary) { $directoryArguments.FrameLibrary = $FrameLibrary }
|
||||
& (Join-Path $PSScriptRoot 'smoke-installer-directories.ps1') @directoryArguments
|
||||
$payload = Join-Path $scratch 'payload'
|
||||
New-Item -ItemType Directory -Path $payload | Out-Null
|
||||
[System.IO.File]::WriteAllText((Join-Path $payload 'locked.txt'), 'new runtime')
|
||||
|
||||
@@ -70,4 +70,13 @@ describe('desktop build version discovery', () => {
|
||||
await expect(suggestDesktopBuildVersion({ productVersion: PRERELEASE, target: 'mac-arm64', environment: {}, date: DATE, artifactsRoot }))
|
||||
.resolves.toBe(`${PRERELEASE}.${DATE}.4`)
|
||||
})
|
||||
|
||||
it('counts unsigned Windows artifacts written under their own suffix', async () => {
|
||||
const artifactsRoot = await artifactsWith([
|
||||
`deepseek-harness-${PRERELEASE}.${DATE}.2-win-x64-unsigned.exe`,
|
||||
`deepseek-harness-${PRERELEASE}.${DATE}.5-win-x64-unsigned.exe.blockmap`,
|
||||
])
|
||||
await expect(suggestDesktopBuildVersion({ productVersion: PRERELEASE, target: 'win-x64', environment: {}, date: DATE, artifactsRoot }))
|
||||
.resolves.toBe(`${PRERELEASE}.${DATE}.3`)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,9 @@ LangString decompressionFailed ${LANG_ENGLISH} "Payload extraction failed"
|
||||
!insertmacro dshExtractPayload "${PAYLOAD_FILE}"
|
||||
!macroend
|
||||
!ifdef SOURCE_DLL
|
||||
LoadLanguageFile "${NSISDIR}\Contrib\Language files\SimpChinese.nlf"
|
||||
!include "..\..\installer\strings.nsh"
|
||||
!define DSH_INSTALLER_LOG_DIR "${REPORT_DIR}"
|
||||
!include "..\..\scripts\installer.nsh"
|
||||
!else
|
||||
!include "..\..\scripts\installer-directories.nsh"
|
||||
|
||||
@@ -57,6 +57,19 @@ describe('installer preparation preserves application dependencies', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('names unsigned Windows artifacts so they cannot pass for release builds', async () => {
|
||||
const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs')
|
||||
const config = createElectronBuilderConfig({
|
||||
DSH_DESKTOP_APP_ID: 'com.example.installer',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
|
||||
DSH_DESKTOP_TARGET_PLATFORM: 'win32',
|
||||
DSH_DESKTOP_TARGET_ARCH: 'x64',
|
||||
DSH_DESKTOP_UNSIGNED: '1',
|
||||
}, 'win32', 'x64')
|
||||
expect(config.artifactName).toBe('deepseek-harness-${version}-${os}-${arch}-unsigned.${ext}')
|
||||
})
|
||||
|
||||
it('packages every preload entry point the shell loads', async () => {
|
||||
const { readdirSync, readFileSync } = await import('node:fs')
|
||||
const sourceDirectory = new URL('../src/', import.meta.url)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Extraction failure reports keep 7-Zip's evidence intact while the dialog headline and excerpt stay bounded.
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define UNICODE
|
||||
#include <windows.h>
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include "../installer/extract-report.h"
|
||||
|
||||
extern "C" __declspec(dllimport) int __cdecl InstallerReportExtractFailure(
|
||||
HWND parent, int code, LPCWSTR archive, LPCWSTR destination, LPCWSTR log, LPCWSTR reportPath, int show,
|
||||
LPCWSTR title, LPCWSTR heading, LPCWSTR hint, LPCWSTR copy, LPCWSTR expand, LPCWSTR collapse, LPCWSTR savedFormat, LPCWSTR unsaved, LPCWSTR copied);
|
||||
|
||||
static bool Contains(const std::wstring& text, const std::wstring& needle) { return text.find(needle) != std::wstring::npos; }
|
||||
|
||||
static std::string ReadBytes(const std::wstring& path) {
|
||||
HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
|
||||
assert(file != INVALID_HANDLE_VALUE);
|
||||
std::string bytes(1 << 16, '\0');
|
||||
DWORD count = 0;
|
||||
const BOOL read = ReadFile(file, &bytes[0], static_cast<DWORD>(bytes.size()), &count, nullptr);
|
||||
assert(read);
|
||||
CloseHandle(file);
|
||||
bytes.resize(count);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static void WriteBytes(const std::wstring& path, const std::string& bytes) {
|
||||
HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, 0, nullptr);
|
||||
assert(file != INVALID_HANDLE_VALUE);
|
||||
DWORD written = 0;
|
||||
const BOOL wrote = WriteFile(file, bytes.data(), static_cast<DWORD>(bytes.size()), &written, nullptr);
|
||||
assert(wrote);
|
||||
CloseHandle(file);
|
||||
}
|
||||
|
||||
int wmain() {
|
||||
using namespace extract_report;
|
||||
|
||||
// Result codes distinguish 7-Zip's own exit codes, Win32 failures around its process, and raw crash statuses.
|
||||
assert(DescribeResult(2) == L"7-Zip exit code 2 (fatal error)");
|
||||
assert(DescribeResult(9) == L"7-Zip exit code 9 (unexpected exit code)");
|
||||
assert(DescribeResult(-5).rfind(L"Windows error 5 while running 7-Zip", 0) == 0);
|
||||
assert(Contains(DescribeResult(-ERROR_FILE_NOT_FOUND), L"Windows error 2"));
|
||||
assert(DescribeResult(static_cast<int>(0xC0000005)) == L"7-Zip terminated with status 0xC0000005");
|
||||
assert(DescribeResult(1000) == L"7-Zip terminated with status 0x000003E8");
|
||||
|
||||
// The headline prefers the line naming the failing operation; the archive path 7-Zip echoes first is skipped
|
||||
// even when it contains spaces.
|
||||
const std::wstring spacedArchive = L"C:\\Users\\Jane Doe\\AppData\\Local\\Temp\\nsa1F2C.tmp\\app-64.7z";
|
||||
const std::wstring broken = L"ERROR: " + spacedArchive + L"\r\napp-64.7z\r\nOpen ERROR: Cannot open the file as [7z] archive\r\n\r\n\r\nERRORS:\r\nIs not archive\r\n";
|
||||
assert(Headline(2, broken, spacedArchive) == L"Open ERROR: Cannot open the file as [7z] archive");
|
||||
assert(Headline(2, L"ERROR: app-64.7z\r\nOpen ERROR: Is not archive\r\n", spacedArchive) == L"Open ERROR: Is not archive");
|
||||
const std::wstring locked = L"ERROR: Cannot delete output file : The process cannot access the file. : C:\\Apps\\Harness.new-1\\resources\\app.node\r\n";
|
||||
assert(Headline(2, locked, spacedArchive) == Trim(locked));
|
||||
assert(Headline(2, L"ERROR: " + spacedArchive + L"\r\n" + locked, spacedArchive) == Trim(locked));
|
||||
assert(Headline(-ERROR_ACCESS_DENIED, L"", spacedArchive) == DescribeResult(-ERROR_ACCESS_DENIED));
|
||||
assert(Headline(2, L"ERROR: " + spacedArchive + L"\r\n", spacedArchive) == DescribeResult(2));
|
||||
assert(Headline(1, L"ERROR: only a note\r\n", spacedArchive) == L"ERROR: only a note");
|
||||
const std::wstring longLine = L"ERROR: Cannot open output file : " + std::wstring(400, L'x');
|
||||
const std::wstring headline = Headline(2, longLine, spacedArchive);
|
||||
assert(headline.size() == kHeadlineLimit && headline.back() == kEllipsis);
|
||||
|
||||
// The report carries the result, both paths, and 7-Zip's output verbatim.
|
||||
const std::wstring report = Compose(2, L"C:\\Temp\\app-64.7z", L"C:\\Apps\\Harness.new-1", locked, L"2026-09-22 10:59:49", L"10.0.26100");
|
||||
assert(Contains(report, L"Result: 7-Zip exit code 2 (fatal error)\r\n"));
|
||||
assert(Contains(report, L"Archive: C:\\Temp\\app-64.7z\r\n"));
|
||||
assert(Contains(report, L"Destination: C:\\Apps\\Harness.new-1\r\n"));
|
||||
assert(Contains(report, L"Windows: 10.0.26100\r\n"));
|
||||
assert(Contains(report, L"\r\n7-Zip output:\r\n" + Trim(locked) + L"\r\n"));
|
||||
assert(Contains(Compose(2, L"a", L"b", L" \r\n", L"t", L"w"), L"7-Zip output:\r\n(none)\r\n"));
|
||||
|
||||
// The excerpt shows the result and 7-Zip's own lines, bounded by lines and characters, and counts what it left out.
|
||||
std::wstring manyErrors;
|
||||
for (int i = 0; i < 40; ++i) manyErrors += L"ERROR: Cannot open output file : Access is denied. : C:\\Apps\\file" + std::to_wstring(i) + L".dll\r\n";
|
||||
const std::wstring excerpt = Excerpt(Compose(2, L"a", L"b", manyErrors, L"t", L"w"));
|
||||
const std::vector<std::wstring> excerptLines = Lines(excerpt);
|
||||
assert(excerptLines.size() == kExcerptLines + 1);
|
||||
assert(excerptLines[0] == L"Result: 7-Zip exit code 2 (fatal error)");
|
||||
assert(excerptLines[1].rfind(L"ERROR: Cannot open output file", 0) == 0);
|
||||
assert(excerpt.size() <= kExcerptChars + 64);
|
||||
assert(Contains(excerpt, L" (" + std::to_wstring(41 - kExcerptLines) + L" more lines in the saved report)"));
|
||||
assert(!Contains(excerpt, L"Archive: "));
|
||||
assert(Excerpt(Compose(2, L"a", L"b", L"one\r\ntwo", L"t", L"w")) == L"Result: 7-Zip exit code 2 (fatal error)\r\none\r\ntwo");
|
||||
assert(Excerpt(Compose(2, L"a", L"b", std::wstring(2000, L'x'), L"t", L"w")).size() < 64 + kExcerptLineLimit + 64);
|
||||
|
||||
// A cut inside a multi-byte sequence is trimmed back to a code point boundary.
|
||||
std::string partial("abc\xE6\x96\x87\xE4\xBB");
|
||||
TrimPartialUtf8(partial);
|
||||
assert(partial == "abc\xE6\x96\x87");
|
||||
std::string complete("abc\xE6\x96\x87");
|
||||
TrimPartialUtf8(complete);
|
||||
assert(complete == "abc\xE6\x96\x87");
|
||||
std::string ascii("abc");
|
||||
TrimPartialUtf8(ascii);
|
||||
assert(ascii == "abc");
|
||||
|
||||
// 7-Zip output arrives as UTF-8; anything else falls back to the system code page instead of vanishing.
|
||||
assert(Decode("Access denied: \xE6\x96\x87\xE4\xBB\xB6") == L"Access denied: \x6587\x4EF6");
|
||||
assert(!Decode("\xC1\xED\xD2\xBB : file").empty());
|
||||
assert(Decode("") == L"");
|
||||
|
||||
// The exported entry point writes the report beside a missing directory and survives a missing log file.
|
||||
WCHAR temp[MAX_PATH];
|
||||
const DWORD tempLength = GetTempPathW(MAX_PATH, temp);
|
||||
assert(tempLength);
|
||||
const std::wstring root = std::wstring(temp) + L"dsh-extract-report-" + std::to_wstring(GetCurrentProcessId());
|
||||
const BOOL created = CreateDirectoryW(root.c_str(), nullptr);
|
||||
assert(created);
|
||||
const std::wstring log = root + L"\\extract.log";
|
||||
WriteBytes(log, "ERROR: Cannot open output file : Access is denied. : C:\\Apps\\Harness.new-1\\\xE6\x96\x87\xE4\xBB\xB6.dll\r\n");
|
||||
const std::wstring reportPath = root + L"\\nested\\logs\\extract-failure.log";
|
||||
const int written = InstallerReportExtractFailure(nullptr, 2, L"C:\\Temp\\app-64.7z", L"C:\\Apps\\Harness.new-1", log.c_str(), reportPath.c_str(), 0,
|
||||
L"title", L"heading", L"hint", L"copy", L"expand", L"collapse", L"saved %s", L"unsaved", L"copied");
|
||||
assert(written == 1);
|
||||
const std::string saved = ReadBytes(reportPath);
|
||||
assert(saved.rfind("\xEF\xBB\xBF", 0) == 0);
|
||||
assert(saved.find("Result: 7-Zip exit code 2 (fatal error)") != std::string::npos);
|
||||
assert(saved.find("Access is denied. : C:\\Apps\\Harness.new-1\\\xE6\x96\x87\xE4\xBB\xB6.dll") != std::string::npos);
|
||||
const std::wstring missingLogReport = root + L"\\missing.log.txt";
|
||||
const int withoutLog = InstallerReportExtractFailure(nullptr, -ERROR_ACCESS_DENIED, L"a", L"b", (root + L"\\absent.log").c_str(), missingLogReport.c_str(), 0,
|
||||
L"title", L"heading", L"hint", L"copy", L"expand", L"collapse", L"saved %s", L"unsaved", L"copied");
|
||||
assert(withoutLog == 1);
|
||||
assert(ReadBytes(missingLogReport).find("Windows error 5 while running 7-Zip") != std::string::npos);
|
||||
assert(ReadBytes(missingLogReport).find("7-Zip output:\r\n(none)") != std::string::npos);
|
||||
// An unwritable report location reports failure without throwing.
|
||||
const int unwritable = InstallerReportExtractFailure(nullptr, 2, L"a", L"b", log.c_str(), (log + L"\\impossible.txt").c_str(), 0,
|
||||
L"title", L"heading", L"hint", L"copy", L"expand", L"collapse", L"saved %s", L"unsaved", L"copied");
|
||||
assert(unwritable == 0);
|
||||
|
||||
for (const std::wstring& file : {reportPath, missingLogReport, log}) DeleteFileW(file.c_str());
|
||||
RemoveDirectoryW((root + L"\\nested\\logs").c_str());
|
||||
RemoveDirectoryW((root + L"\\nested").c_str());
|
||||
const BOOL removed = RemoveDirectoryW(root.c_str());
|
||||
assert(removed);
|
||||
return 0;
|
||||
}
|
||||
@@ -90,6 +90,7 @@ describe('Windows update publisher', () => {
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
|
||||
}, 'win32', 'x64')
|
||||
expect(config.win.forceCodeSigning).toBe(true)
|
||||
expect(config.artifactName).toBe('deepseek-harness-${version}-${os}-${arch}.${ext}')
|
||||
expect(typeof config.win.signtoolOptions.sign).toBe('function')
|
||||
const manager = new WindowsSignToolManager({ platformSpecificBuildOptions: config.win, getCscLink: () => undefined })
|
||||
expect(await manager.computedPublisherName.value).toEqual(['CN=Publisher,O=Company,C=CN'])
|
||||
|
||||
@@ -121,24 +121,27 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
within(contextPanel).getByText('Tool definitions')
|
||||
within(contextPanel).getByText('Messages')
|
||||
|
||||
// The write/edit turns render a real diff card through the assembled graph
|
||||
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
|
||||
// fixture's raw text. The card is collapsed by default, so expand each edit/
|
||||
// write row first. The write turn's `hello fixture\n` proves the terminator
|
||||
// rule end to end: a trailing newline terminates its line, so the footer reads
|
||||
// `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS
|
||||
// ::before, so it is absent from textContent — assert on the line body and the
|
||||
// footer.
|
||||
const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')]
|
||||
// The built ToolRow and DiffBlock share the write fixture's trailing-newline
|
||||
// semantics: `hello fixture\n` contributes one addition. Counts belong to the
|
||||
// tool row; the diff's line markers are CSS pseudo-elements, absent from textContent.
|
||||
const mutationRows = [...document.querySelectorAll<HTMLElement>('[data-variant="write"],[data-variant="edit"]')]
|
||||
expect(mutationRows.length).toBeGreaterThan(0)
|
||||
const writeRow = mutationRows.find(row => row.getAttribute('data-variant') === 'write'
|
||||
&& row.textContent?.includes('new-demo.txt'))
|
||||
if (writeRow === undefined) throw new Error('fixture write row missing')
|
||||
expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1)
|
||||
expect(writeRow.querySelector('[data-diff]')).toBeNull()
|
||||
for (const row of mutationRows) {
|
||||
const toggle = row.querySelector('[data-expandable]')
|
||||
if (toggle !== null) act(() => { fireEvent.click(toggle) })
|
||||
}
|
||||
const diffCards = [...document.querySelectorAll('[data-diff]')]
|
||||
expect(diffCards.length).toBeGreaterThan(0)
|
||||
const footers = diffCards.map(card => card.textContent ?? '')
|
||||
expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
|
||||
const writeDiff = writeRow.querySelector<HTMLElement>('[data-diff]')
|
||||
if (writeDiff === null) throw new Error('expanded fixture write diff missing')
|
||||
within(writeDiff).getByText('hello fixture', { exact: true })
|
||||
expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1)
|
||||
expect(writeDiff.textContent).not.toContain('+1 -0')
|
||||
|
||||
// The web render intent reaches the assembled boot graph: the fixture's
|
||||
// web_search / web_fetch turns render their keyed WebRow cards, proving the
|
||||
|
||||
@@ -454,8 +454,8 @@ describe('web e2e: clickable links gallery', () => {
|
||||
await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL)
|
||||
|
||||
await openSettings(page, 'en')
|
||||
await page.getByRole('button', { name: 'Built-in browser', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'New browser tab', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'In-App Sidebar', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Default Browser', exact: true }).click()
|
||||
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ linkOpening: 'new-tab' })
|
||||
await page.keyboard.press('Escape')
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
@@ -471,8 +471,8 @@ describe('web e2e: clickable links gallery', () => {
|
||||
|
||||
await page.reload()
|
||||
await openSettings(page, 'en')
|
||||
await page.getByRole('button', { name: 'New browser tab', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Built-in browser', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Default Browser', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'In-App Sidebar', exact: true }).click()
|
||||
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ linkOpening: 'sidebar' })
|
||||
await page.keyboard.press('Escape')
|
||||
await guideLink.click()
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/** Browser geometry for read offsets and horizontally scrolled diff fills. */
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const LONG_LINE = 'source_columns_'.repeat(80)
|
||||
const OFFSETS = [999, 99_999]
|
||||
|
||||
/** Seed durable tool results without invoking a model or allocating a large file. */
|
||||
function layoutFixture(): string {
|
||||
const calls: { name: string; args: Record<string, JsonValue>; result: string; meta: JsonValue }[] = OFFSETS.map((offset) => {
|
||||
const path = `offset-${offset}.txt`
|
||||
const lines = [{ number: offset, text: 'short' }, { number: offset + 1, text: LONG_LINE }]
|
||||
return {
|
||||
name: 'read', args: { file_path: path, offset },
|
||||
result: `<path>${path}</path>\n<type>file</type>\n<content>\n${lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n</content>`,
|
||||
meta: { path, offset, lines, totalLines: offset + 2 },
|
||||
}
|
||||
})
|
||||
const oldText = `old_${LONG_LINE}\nshort old`
|
||||
const newText = `new_${LONG_LINE}\nshort new`
|
||||
calls.push({
|
||||
name: 'edit', args: { file_path: 'long.txt', old_string: oldText, new_string: newText },
|
||||
result: 'Edited long.txt', meta: { diffs: [{ path: 'long.txt', oldText, newText }] },
|
||||
})
|
||||
const session = Session.create(SessionId('code-card-layout-source'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Inspect the source windows and edit the long lines.' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const namedCalls = calls.map((call, index) => ({ ...call, id: ToolCallId(`layout-${index}`), arguments: JSON.stringify(call.args) }))
|
||||
session.append('assistant/message', {
|
||||
stream: [], turn: 1, step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: namedCalls.map(call => ({ type: 'tool-call', id: call.id, name: call.name, arguments: call.arguments })),
|
||||
source: { provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
for (const call of namedCalls) {
|
||||
const source = session.append('tool/call', { turn: 1, step: 1, callId: call.id, name: call.name, arguments: call.arguments })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, meta: call.meta,
|
||||
message: createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: call.result }], isError: false }),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
|
||||
}
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
session.append('assistant/message', {
|
||||
stream: [], turn: 1, step: 2,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'CODE_LAYOUT_DONE' }], source: { provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 2 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return [
|
||||
JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0 }),
|
||||
...session.snapshotEvents().map(event => JSON.stringify(event)), '',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: code-card layout', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, layoutFixture(), 'code-card-layout')
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.locator('[role="treeitem"]').first().click()
|
||||
await page.locator('[role="treeitem"]').nth(1).click()
|
||||
await page.getByText('CODE_LAYOUT_DONE', { exact: true }).waitFor()
|
||||
await expandTurnProcesses(page)
|
||||
for (const row of await page.locator('[data-variant="read"], [data-variant="edit"]').all()) {
|
||||
await row.locator('[data-expandable]').click()
|
||||
}
|
||||
await page.locator('[data-read]').nth(1).waitFor()
|
||||
await page.locator('[data-diff]').waitFor()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
try { await browser?.close() } finally { await scaffold?.close() }
|
||||
})
|
||||
|
||||
it.each(OFFSETS)('keeps offset %i line numbers clear of aligned source in both wrap modes', async (offset) => {
|
||||
onTestFailed(() => saveFailureShot(page, `code-card-offset-${offset}`))
|
||||
const card = page.locator('[data-read]').filter({ hasText: `offset-${offset}.txt` })
|
||||
const wrap = card.getByRole('button', { name: 'Wrap lines', exact: true })
|
||||
const heights: number[] = []
|
||||
for (const wrapped of [false, true]) {
|
||||
if (wrapped) await wrap.click()
|
||||
expect(await wrap.getAttribute('aria-pressed')).toBe(String(wrapped))
|
||||
const rows = await card.locator('[class*="gutter"]').evaluateAll(gutters => gutters.map((gutter) => {
|
||||
const content = gutter.nextElementSibling!
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(gutter)
|
||||
const glyphs = range.getBoundingClientRect()
|
||||
const slot = gutter.getBoundingClientRect()
|
||||
const source = content.getBoundingClientRect()
|
||||
return {
|
||||
fits: glyphs.left >= slot.left - 1 && glyphs.right <= slot.right + 1,
|
||||
gap: source.left - glyphs.right, left: source.left, height: source.height,
|
||||
}
|
||||
}))
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.every(row => row.fits && row.gap >= 11)).toBe(true)
|
||||
expect(rows[0]!.left).toBe(rows[1]!.left)
|
||||
heights.push(rows[1]!.height)
|
||||
}
|
||||
expect(heights[1]!).toBeGreaterThan(heights[0]!)
|
||||
await wrap.click()
|
||||
})
|
||||
|
||||
it.each([false, true])('fills long diff rows through horizontal scrolling with dark=%s', async (dark) => {
|
||||
onTestFailed(() => saveFailureShot(page, `code-card-diff-${dark}`))
|
||||
await page.evaluate(value => document.body.toggleAttribute('data-ds-dark-theme', value), dark)
|
||||
const card = page.locator('[data-diff]')
|
||||
const body = card.locator(':scope > div').nth(1)
|
||||
const wrap = card.getByRole('button', { name: 'Wrap lines', exact: true })
|
||||
for (const wrapped of [false, true]) {
|
||||
if (wrapped) await wrap.click()
|
||||
const result = await body.evaluate((element) => {
|
||||
element.scrollLeft = element.scrollWidth
|
||||
const viewport = element.getBoundingClientRect()
|
||||
const rows = [...element.children].filter(row => ['+ ', '- '].includes(getComputedStyle(row, '::before').content.replaceAll('"', '')))
|
||||
return {
|
||||
overflow: element.scrollWidth - element.clientWidth,
|
||||
fills: rows.map(row => ({
|
||||
width: row.getBoundingClientRect().width, right: row.getBoundingClientRect().right,
|
||||
fill: getComputedStyle(row).backgroundColor, marker: getComputedStyle(row).boxShadow,
|
||||
})),
|
||||
scrollWidth: element.scrollWidth, right: viewport.right,
|
||||
}
|
||||
})
|
||||
expect(result.fills).toHaveLength(4)
|
||||
if (wrapped) expect(result.overflow).toBeLessThanOrEqual(1)
|
||||
else expect(result.overflow).toBeGreaterThan(100)
|
||||
for (const row of result.fills) {
|
||||
expect(row.width).toBeGreaterThanOrEqual(result.scrollWidth - 1)
|
||||
expect(row.right).toBeGreaterThanOrEqual(result.right - 1)
|
||||
expect(row.fill).toMatch(dark ? /0\.12\)$/ : /0\.08\)$/)
|
||||
expect(row.marker).toContain('inset')
|
||||
}
|
||||
}
|
||||
await wrap.click()
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -55,7 +55,8 @@ describe.skipIf(MODE === 'record').each(CASES)('web e2e: $name', (scenario) => {
|
||||
const card = edit.locator('[data-diff]')
|
||||
await card.waitFor()
|
||||
expect(await card.getByText(scenario.shared, { exact: true }).count()).toBe(1)
|
||||
expect(await card.textContent()).toContain(`${scenario.totals} · 1 file`)
|
||||
expect(await edit.getByText(scenario.totals, { exact: true }).count()).toBe(1)
|
||||
expect(await card.textContent()).not.toContain(scenario.totals)
|
||||
const snapshotDir = `${ROOT}/web/${scenario.name}`
|
||||
await compareOrRefreshGolden(`${snapshotDir}/ui.expected.md`,
|
||||
await captureStableAria(page, '[data-variant="edit"]', scaffold.workspaceCwd), MODE)
|
||||
|
||||
@@ -104,6 +104,58 @@ async function hideDocumentZoom(page: Page, preview: Locator): Promise<void> {
|
||||
await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull()
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while retaining the selected cell', async () => {
|
||||
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
const page = await newEnglishPage(browser)
|
||||
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${process.pid}`) })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
|
||||
if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
|
||||
await writeFile(join(cwd, 'budget.xlsx'), await excelFixture())
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
|
||||
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'budget.xlsx', exact: true }).click()
|
||||
const excel = column.locator('[data-excel-preview]')
|
||||
await excel.locator('.luckysheet-sheets-item-name').getByText('公式与格式', { exact: true }).click()
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 140, y: 30 } })
|
||||
const formula = excel.locator('.fortune-fx-input')
|
||||
await expect.poll(() => formula.innerText()).toBe('46281')
|
||||
const canvas = excel.locator('canvas').first()
|
||||
const originalCanvas = await canvas.elementHandle()
|
||||
const original = await canvas.boundingBox()
|
||||
if (original === null || originalCanvas === null) throw new Error('spreadsheet canvas is unavailable')
|
||||
const panel = page.locator('[data-sidebar-right-panel]')
|
||||
const panelBounds = await panel.boundingBox()
|
||||
if (panelBounds === null) throw new Error('sidebar panel is unavailable')
|
||||
const viewport = page.viewportSize()
|
||||
const layout = await page.addStyleTag({ content: `[data-sidebar-right-panel] { width: ${panelBounds.width + 160}px !important; }` })
|
||||
await expect.poll(async () => Math.round((await canvas.boundingBox())!.width - original.width)).toBe(160)
|
||||
await expect.poll(() => formula.innerText()).toBe('46281')
|
||||
expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(page.viewportSize()).toEqual(viewport)
|
||||
await successShot(page, 'excel-resize-wide')
|
||||
await layout.evaluate((node) => { node.textContent = '' })
|
||||
await expect.poll(async () => Math.round((await canvas.boundingBox())!.width)).toBe(Math.round(original.width))
|
||||
expect(await formula.innerText()).toBe('46281')
|
||||
await successShot(page, 'excel-resize-restored')
|
||||
await originalCanvas.dispose()
|
||||
} finally {
|
||||
try { await browser?.close() } finally { await scaffold.close() }
|
||||
}
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
@@ -799,7 +851,12 @@ else process.exit(1);
|
||||
})).toEqual({ scrollTop, scrollportBelowBanner: true, firstLineAbove: true })
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
|
||||
await page.evaluate(() => navigator.clipboard.writeText(''))
|
||||
await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click()
|
||||
const copyCode = codeBlock.getByRole('button', { name: 'Copy', exact: true })
|
||||
expect(await codeBlock.getByRole('button').count()).toBe(1)
|
||||
expect(await copyCode.textContent()).toBe('')
|
||||
await copyCode.hover()
|
||||
await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor()
|
||||
await copyCode.click()
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(codeLines.join('\n'))
|
||||
sections.push([
|
||||
'## Code paging', '',
|
||||
|
||||
@@ -34,8 +34,10 @@
|
||||
- text: Edit
|
||||
- button "src/tokens.css"
|
||||
- text: +1 -1
|
||||
- text: css
|
||||
- button "Wrap lines"
|
||||
- button "Copy"
|
||||
- text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5; └ +1 -1 · 1 file"
|
||||
- text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5;"
|
||||
- button "Inspect"
|
||||
- button "Tool call str_replace_editor · create" [expanded]
|
||||
- text: "IN { \"command\": \"create\", \"path\": \"docs/press.md\", \"file_text\": \"# Press kit\\n\" } OUT Created docs/press.md"
|
||||
@@ -45,7 +47,8 @@
|
||||
- button "Read docs/guide.md" [expanded]:
|
||||
- text: Read
|
||||
- button "docs/guide.md"
|
||||
- text: docs/guide.md
|
||||
- text: Code block docs/guide.md
|
||||
- button "Wrap lines"
|
||||
- button "Copy"
|
||||
- text: "# Link style guide guide line 2 guide line 3 guide line 4"
|
||||
- button "Expand 4 more lines": … 4 more lines
|
||||
@@ -136,6 +139,7 @@
|
||||
- paragraph:
|
||||
- img "Token preview"
|
||||
- text: css
|
||||
- button "Wrap lines" [pressed]
|
||||
- button "Copy"
|
||||
- code: "--inline-code: #F5F5F5;"
|
||||
- paragraph: LINK_GALLERY_DONE
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- tab "Host" [selected]
|
||||
- tabpanel "Host":
|
||||
- text: javascript
|
||||
- button "Wrap lines" [pressed]
|
||||
- button "Copy"
|
||||
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
|
||||
- text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
- text: Code block
|
||||
- button "Wrap lines" [pressed]
|
||||
- button "Copy"
|
||||
- code: echo WATERFALL
|
||||
@@ -214,6 +214,7 @@
|
||||
- text: .
|
||||
- math: a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + 1 1 + 1 x
|
||||
- text: typescript
|
||||
- button "Wrap lines" [pressed]
|
||||
- button "Copy"
|
||||
- code: const value = "reasoning code"
|
||||
- paragraph: {{longToken}}
|
||||
@@ -27,6 +27,8 @@ const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
|
||||
const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md')
|
||||
const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
|
||||
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url))
|
||||
const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'navigation-panes-web-e2e'
|
||||
const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl`
|
||||
@@ -218,6 +220,28 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
|
||||
}, 90_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('renders recorded Markdown with icon tooltips and switchable wrapping', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-code-card'))
|
||||
await ensureSeedOpen(page)
|
||||
const selector = '.md-code-block:has-text("echo WATERFALL")'
|
||||
const block = page.locator(selector)
|
||||
await block.waitFor({ timeout: 15_000 })
|
||||
await compareOrRefreshGolden(CODE_CARD_EXPECTED, await captureStableAria(page, selector, scaffold.workspaceCwd), MODE)
|
||||
const code = block.locator('pre')
|
||||
const source = await code.textContent()
|
||||
const copy = block.getByRole('button', { name: 'Copy', exact: true })
|
||||
await copy.hover()
|
||||
await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor()
|
||||
const unwrap = block.getByRole('button', { name: 'Wrap lines', exact: true })
|
||||
await unwrap.hover()
|
||||
await page.getByRole('tooltip', { name: 'Do not wrap lines', exact: true }).waitFor()
|
||||
await unwrap.click()
|
||||
await expect.poll(() => code.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre')
|
||||
await block.getByRole('button', { name: 'Wrap lines', exact: true }).click()
|
||||
await expect.poll(() => code.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap')
|
||||
expect(await code.textContent()).toBe(source)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
|
||||
await ensureSeedOpen(page)
|
||||
@@ -521,6 +545,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
|
||||
await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
|
||||
'terminal-card.expected.md', 'timing.expected.md',
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- status: Deep diving...
|
||||
- button "Deep diving for {{duration}}" [disabled] [expanded]
|
||||
- text: ts
|
||||
- button "Wrap lines" [pressed]
|
||||
- button "Copy"
|
||||
- code: "const first: number = 1 const second = \"two\" let tail"
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
|
||||
@@ -85,7 +85,7 @@ async function fenceTree(block: ReturnType<Page['locator']>): Promise<FenceTree>
|
||||
const pre = element.querySelector<HTMLPreElement>('pre.shiki')
|
||||
if (pre === null) throw new Error('streaming fence did not render through the shiki arm')
|
||||
return {
|
||||
language: element.querySelector('[class*="infostring"]')?.textContent ?? '',
|
||||
language: element.querySelector('[data-code-block-banner] [class*="language"]')?.textContent ?? '',
|
||||
pre: {
|
||||
className: pre.className,
|
||||
style: pre.style.cssText,
|
||||
|
||||
@@ -170,6 +170,8 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
await page.evaluate(async () => { await document.fonts.ready })
|
||||
const styles = await markdown.evaluate((root, secondary) => {
|
||||
const elements = [...root.querySelectorAll<HTMLElement>('h1,h2,h3,h4,h5,h6,p,li,a,strong,em,pre,pre code,pre span,th,td,.katex')]
|
||||
const prose = elements.filter(element => element.closest('pre') === null)
|
||||
const code = elements.filter(element => element.closest('pre') !== null)
|
||||
const scrollers = [...root.querySelectorAll<HTMLElement>('[class*="tableScroll"],.katex-display,p:has(.katex),ul:has(.katex)')]
|
||||
const shortFormulas = [...root.querySelectorAll('p')].filter(element => element.textContent?.startsWith('Short '))
|
||||
// Native KaTeX baselines vary with host fonts; compact adds one pixel for descender ink.
|
||||
@@ -190,9 +192,13 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
const banner = root.querySelector('[data-code-block-banner]')?.parentElement
|
||||
const loose = [...root.querySelectorAll('li p')].find(element => element.textContent === 'Loose middle paragraph.')
|
||||
return {
|
||||
secondarySize: elements.every(element => getComputedStyle(element).fontSize === secondary.fontSize),
|
||||
secondaryLine: elements.filter(element => !element.classList.contains('katex'))
|
||||
secondarySize: prose.every(element => getComputedStyle(element).fontSize === secondary.fontSize),
|
||||
secondaryLine: prose.filter(element => !element.classList.contains('katex'))
|
||||
.every(element => getComputedStyle(element).lineHeight === secondary.lineHeight),
|
||||
codeTypography: code.length > 0 && code.every((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
return style.fontSize === '11px' && style.lineHeight === '19px'
|
||||
}),
|
||||
tertiaryColor: elements.every(element => getComputedStyle(element).color === secondary.color),
|
||||
tertiaryMarkers: [...root.querySelectorAll('li')]
|
||||
.every(element => getComputedStyle(element, '::marker').color === secondary.color),
|
||||
@@ -216,7 +222,7 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
}
|
||||
}, summaryStyle)
|
||||
expect(styles, `Thinking typography at ${String(width)}px`).toEqual({
|
||||
secondarySize: true, secondaryLine: true, tertiaryColor: true, tertiaryMarkers: true,
|
||||
secondarySize: true, secondaryLine: true, codeTypography: true, tertiaryColor: true, tertiaryMarkers: true,
|
||||
contained: true, bannerStatic: true, looseSpacing: true,
|
||||
wideContentBounded: true, displayMathScrolls: true,
|
||||
shortMathNaturalHeight: true, shortMathNoScrollbar: true, longAtomScrolls: true, mathListMarkerSpace: true,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"tests/server-restart.e2e.ts",
|
||||
"tests/default-product-isolation.e2e.ts",
|
||||
"tests/diff-context.e2e.ts",
|
||||
"tests/code-card-layout.e2e.ts",
|
||||
"tests/scaffold.ts",
|
||||
"tests/voice-input.e2e.ts",
|
||||
"tests/voice-setup.e2e.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: d65619dfdb2f1f8406f93ba32cdcca5b27dace75
|
||||
config-catalog.zh.md: 1dc8a03f22c98b1e388032bd51f0a5e5b2f65c6e
|
||||
config-catalog.md: fc5a8e12cf94715934d484121dfbbccc7ffc4252
|
||||
config-catalog.zh.md: 26c7807c997c41a208e047965eec59eeec925a9c
|
||||
@@ -3405,9 +3405,11 @@ export interface Config {
|
||||
completionDelivery?: CompletionDelivery
|
||||
/**
|
||||
* Turns one owner may have opened by completion wakes before the next
|
||||
* notice degrades to injection, reset by any user-authored input (default 3).
|
||||
* Bounds the self-exciting chain where a woken turn starts the job whose
|
||||
* completion wakes it again.
|
||||
* notice degrades to injection, reset by any user-authored input. Absent by
|
||||
* default: every idle completion wakes its owner. Set it to bound the
|
||||
* self-exciting chain where a woken turn starts the job whose completion
|
||||
* wakes it again, at the cost of notices past the cap waiting silently for
|
||||
* the next user input.
|
||||
*/
|
||||
maxConsecutiveWakes?: number
|
||||
}
|
||||
|
||||
@@ -3407,9 +3407,11 @@ export interface Config {
|
||||
completionDelivery?: CompletionDelivery
|
||||
/**
|
||||
* Turns one owner may have opened by completion wakes before the next
|
||||
* notice degrades to injection, reset by any user-authored input (default 3).
|
||||
* Bounds the self-exciting chain where a woken turn starts the job whose
|
||||
* completion wakes it again.
|
||||
* notice degrades to injection, reset by any user-authored input. Absent by
|
||||
* default: every idle completion wakes its owner. Set it to bound the
|
||||
* self-exciting chain where a woken turn starts the job whose completion
|
||||
* wakes it again, at the cost of notices past the cap waiting silently for
|
||||
* the next user input.
|
||||
*/
|
||||
maxConsecutiveWakes?: number
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ export const en = {
|
||||
'close': 'Close',
|
||||
'copy': 'Copy',
|
||||
'copied': 'Copied',
|
||||
'codeBlock.title': 'Code block',
|
||||
'codeBlock.wrap': 'Wrap lines',
|
||||
'codeBlock.unwrap': 'Do not wrap lines',
|
||||
'copy.failed': 'Copy failed',
|
||||
'copy.value': 'Copy value',
|
||||
'copy.json': 'Copy JSON',
|
||||
|
||||
@@ -5,6 +5,9 @@ export const zh = {
|
||||
'close': '关闭',
|
||||
'copy': '复制',
|
||||
'copied': '复制成功',
|
||||
'codeBlock.title': '代码块',
|
||||
'codeBlock.wrap': '自动换行',
|
||||
'codeBlock.unwrap': '取消自动换行',
|
||||
'copy.failed': '复制失败',
|
||||
'copy.value': '复制值',
|
||||
'copy.json': '复制 JSON',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Read-only help stays local to Settings and never changes the selected preset. */
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { KeyboardEvent, ReactNode } from 'react'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { Button, IconCloseOutlineRegular, IconListPenOutlineRegular, MarkdownText, Modal, SegmentedTabs, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { AgentPresetSettingsKey } from './locales.ts'
|
||||
import css from './PresetGuideDialog.module.css'
|
||||
@@ -35,10 +36,10 @@ export function presetGuide(id: string, trust: string): PresetGuide | undefined
|
||||
/** Curated usage dictionaries contain only level-three example sections. */
|
||||
function GuideUsage({ text, t }: {
|
||||
text: string
|
||||
t: (key: AgentPresetSettingsKey) => string
|
||||
t: TranslateNS<'settings.agentPreset'>
|
||||
}): ReactNode {
|
||||
const labels = {
|
||||
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') },
|
||||
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
|
||||
footnotes: t('guideFootnotes'),
|
||||
}
|
||||
return text.split(/(?=^### )/m).map((section) => {
|
||||
@@ -67,7 +68,7 @@ function GuideUsage({ text, t }: {
|
||||
export function PresetGuideDialog({ guide, initialPage, t, onClose }: {
|
||||
guide: PresetGuide
|
||||
initialPage: PresetGuidePage
|
||||
t: (key: AgentPresetSettingsKey) => string
|
||||
t: TranslateNS<'settings.agentPreset'>
|
||||
onClose: () => void
|
||||
}): ReactNode {
|
||||
const [page, setPage] = useState(initialPage)
|
||||
@@ -144,7 +145,7 @@ export function PresetGuideDialog({ guide, initialPage, t, onClose }: {
|
||||
<MarkdownText
|
||||
text={t(guide.explanation)}
|
||||
labels={{
|
||||
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') },
|
||||
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
|
||||
footnotes: t('guideFootnotes'),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
|
||||
README.md: 14a5260e23640d58c73779cff76bd27853bf4685
|
||||
README.zh.md: 94e1442c5a1afbe3e57df8e2ac2e22181becf253
|
||||
README.md: 285242c9dc9b8225711096aed03f183f5a8e36b7
|
||||
README.zh.md: 8844c9c361f7404af4ea22c645f18e2c9c4e1799
|
||||
@@ -33,7 +33,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
|
||||
|
||||
Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state.
|
||||
|
||||
Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: Built-in browser (default) opens a new right-Sidebar Browser tab, while New browser tab opens an external tab. The setting is shown only while the built-in browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
|
||||
Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: In-App Sidebar (default) opens a new right-Sidebar Browser tab, while Default Browser opens an external tab. The setting is shown only while the Sidebar Browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
|
||||
|
||||
<a id="system-prompt-row"></a>
|
||||
## Hidden Chat rows
|
||||
|
||||
@@ -33,7 +33,7 @@ kind: "package-reference"
|
||||
|
||||
Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。
|
||||
|
||||
设置 → 通用设置 → 聊天链接打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「内置浏览器」(默认)打开新的右侧 Sidebar Browser tab,「浏览器新标签页」打开外部标签页。该设置项仅在内置浏览器可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
|
||||
设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
|
||||
|
||||
<a id="system-prompt-row"></a>
|
||||
## Chat 隐藏的行
|
||||
|
||||
@@ -74,10 +74,10 @@ export const zh = {
|
||||
'settings.performance.description': '选择性能与用量信息展示的详细程度',
|
||||
'settings.performance.compact': '简洁',
|
||||
'settings.performance.detailed': '详细',
|
||||
'settings.links.title': '聊天链接打开方式',
|
||||
'settings.links.description': '选择聊天中 HTTP(S) 链接的打开位置',
|
||||
'settings.links.sidebar': '内置浏览器',
|
||||
'settings.links.newTab': '浏览器新标签页',
|
||||
'settings.links.title': '网页链接默认打开方式',
|
||||
'settings.links.description': '对话中网页链接的打开位置',
|
||||
'settings.links.sidebar': '应用内侧边栏',
|
||||
'settings.links.newTab': '默认浏览器',
|
||||
'settings.transcript.title': '工作过程展示',
|
||||
'settings.transcript.description': '控制轮次和步骤的默认展开方式',
|
||||
'settings.transcript.compact': '简洁',
|
||||
@@ -233,9 +233,9 @@ export const en = {
|
||||
'settings.performance.compact': 'Compact',
|
||||
'settings.performance.detailed': 'Detailed',
|
||||
'settings.links.title': 'Open chat links in',
|
||||
'settings.links.description': 'Choose where HTTP(S) links in chat open',
|
||||
'settings.links.sidebar': 'Built-in browser',
|
||||
'settings.links.newTab': 'New browser tab',
|
||||
'settings.links.description': 'Choose where to open web links',
|
||||
'settings.links.sidebar': 'In-App Sidebar',
|
||||
'settings.links.newTab': 'Default Browser',
|
||||
'settings.transcript.title': 'Work details',
|
||||
'settings.transcript.description': 'Controls how turns and steps expand by default',
|
||||
'settings.transcript.compact': 'Compact',
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ChatViewSlotProps } from './contract/slots.ts'
|
||||
*/
|
||||
export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels {
|
||||
return {
|
||||
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
|
||||
code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
|
||||
footnotes: t('markdown.footnotes'),
|
||||
}
|
||||
}
|
||||
@@ -127,16 +127,16 @@ describe('LinkOpeningRow', () => {
|
||||
/>)
|
||||
expect(screen.queryByText('Open chat links in')).toBeNull()
|
||||
act(() => { browserAvailable.set(true) })
|
||||
expect(screen.getByRole('button', { name: 'New browser tab' })).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: 'Default Browser' })).toBeDefined()
|
||||
act(() => { browserAvailable.set(false) })
|
||||
expect(screen.queryByText('Open chat links in')).toBeNull()
|
||||
expect(source.getSnapshot()).toBe('new-tab')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[en, 'Open chat links in', 'Built-in browser', 'New browser tab'],
|
||||
[zh, '聊天链接打开方式', '内置浏览器', '浏览器新标签页'],
|
||||
] as const)('selects either destination using localized labels (%s)', (dictionary, title, sidebar, newTab) => {
|
||||
[en, 'Open chat links in', 'Choose where to open web links', 'In-App Sidebar', 'Default Browser'],
|
||||
[zh, '网页链接默认打开方式', '对话中网页链接的打开位置', '应用内侧边栏', '默认浏览器'],
|
||||
] as const)('selects either destination using localized labels (%s)', (dictionary, title, description, sidebar, newTab) => {
|
||||
const b = mount('compact', dictionary)
|
||||
const source = createSnapshotStore<LinkOpening>('sidebar')
|
||||
const setLinkOpening = vi.fn((destination: LinkOpening) => { source.set(destination) })
|
||||
@@ -147,6 +147,7 @@ describe('LinkOpeningRow', () => {
|
||||
setLinkOpening={setLinkOpening}
|
||||
/>)
|
||||
expect(screen.getByText(title)).toBeDefined()
|
||||
expect(screen.getByText(description)).toBeDefined()
|
||||
fireEvent.click(screen.getByRole('button', { name: sidebar }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: newTab }))
|
||||
expect(setLinkOpening).toHaveBeenLastCalledWith('new-tab')
|
||||
|
||||
@@ -307,8 +307,6 @@ export const zh = {
|
||||
'tool.autoReviewRejected': 'Auto review 已拒绝',
|
||||
'tool.autoReviewNotExecuted': '工具未执行。原因:{reason}',
|
||||
'tool.autoReviewReasonFallback': 'Auto review 未授权此次操作',
|
||||
'diff.files.one': '{count} 个文件',
|
||||
'diff.files.other': '{count} 个文件',
|
||||
'diff.collapseAria': '收起差异',
|
||||
'diff.expandAria': '展开其余 {count} 行差异',
|
||||
'diff.expandRest': '… 其余 {count} 行',
|
||||
@@ -661,8 +659,6 @@ export const en = {
|
||||
'tool.autoReviewRejected': 'Rejected by Auto review',
|
||||
'tool.autoReviewNotExecuted': 'Tool was not executed. Reason: {reason}',
|
||||
'tool.autoReviewReasonFallback': 'Auto review did not authorize this action',
|
||||
'diff.files.one': '{count} file',
|
||||
'diff.files.other': '{count} files',
|
||||
'diff.collapseAria': 'Collapse diff',
|
||||
'diff.expandAria': 'Expand {count} more diff lines',
|
||||
'diff.expandRest': '… {count} more lines',
|
||||
|
||||
@@ -22,7 +22,7 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) {
|
||||
const params = tab.tab.navigation.params
|
||||
const plan = temporary ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value
|
||||
const labels = useMemo(() => ({
|
||||
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
|
||||
code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
|
||||
footnotes: t('markdown.footnotes'),
|
||||
}), [t])
|
||||
if (plan === undefined) return (
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 7e8a6e2e4ae22d3f9e4d6413c783f4d4da9fed6e
|
||||
README.zh.md: bdbd2faef9ec95a842540c861b934d47c6575e1e
|
||||
README.md: 43476f2aa531decbe1eff1b6664530f7873723b4
|
||||
README.zh.md: 44f5c6e123bd7d6e861fec9554beb2fccf827927
|
||||
@@ -87,7 +87,9 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and
|
||||
|
||||
`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.
|
||||
|
||||
`DiffBlock` compares the old and new content by line. It shows actual additions and deletions with up to three neutral context lines on each side, separates distant changes with `⋯`, and excludes shared context from both summary and footer totals. Search stops beyond 256 line additions/deletions per fragment; those fragments display and count the complete old and new contents as a coarse replacement, including shared lines. Copy includes the full displayed diff with its prefixes. A final newline is treated as a terminator; differences only in the presence of a final newline are not displayed.
|
||||
`CodeBlock.toolbarLabels` enables the shared code-card header and spacing. `ReadBlock` and `DiffBlock` use the same header: tertiary-colored language and filename text, secondary-colored copy and wrap icons, and hover or keyboard-focus tooltips. Missing or unsupported languages use the owner’s localized code-block title; mixed-language diffs use that title too. Code cards share typography and content insets; their headers and bodies use the same background in both themes. Read gutters fit the largest returned line number, and diff fills cover the full scrollable row. The wrap toggle keeps a fixed accessible name and exposes its state through `aria-pressed`; its tooltip describes the next action. Wrapping is local to each mounted card and never changes copied text; code fences start wrapped, while reads and diffs preserve source columns until wrapping is enabled. Compact Markdown shares this toolbar, spacing, and code typography while retaining monochrome text. With `toolbarLabels`, source previews can pass `wrap` to follow their owner’s preference and show only the language and copy icon; owner CSS retains the source layout.
|
||||
|
||||
`DiffBlock` compares the old and new content by line. It shows actual additions and deletions with up to three neutral context lines on each side, separates distant changes with `⋯`, and excludes shared context from tool-summary totals. The card ends after the diff body. Search stops beyond 256 line additions/deletions per fragment; those fragments display and count the complete old and new contents as a coarse replacement, including shared lines. Copy includes the full displayed diff with its prefixes. A final newline is treated as a terminator; differences only in the presence of a final newline are not displayed.
|
||||
|
||||
`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree.
|
||||
|
||||
|
||||
@@ -87,7 +87,9 @@ kind: "package-library"
|
||||
|
||||
`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
|
||||
|
||||
`DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,摘要和底部统计都不计入共享上下文。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。
|
||||
`CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:tertiary 色的语言名称与文件名、secondary 色的复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距;在两种主题下,标题栏和正文都使用相同背景。读取卡片的行号栏容纳返回窗口中的最大行号,Diff 底色覆盖完整可滚动行。换行按钮保留固定的无障碍名称,以 `aria-pressed` 表达状态;提示文本说明下一步操作。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 共用此工具栏、间距和代码字体排版,同时保留单色文本。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。
|
||||
|
||||
`DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,工具摘要的统计不计入共享上下文。卡片在差异正文后结束。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。
|
||||
|
||||
`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
.card {
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: 12px;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 18px 8px 22px;
|
||||
background: var(--dsl-code-block-background, var(--dsw-alias-markdown-code-block));
|
||||
border-top-left-radius: inherit;
|
||||
border-top-right-radius: inherit;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: 11px/18px var(--dsw-font-family);
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.language {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
flex: none;
|
||||
font-family: var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-right: 8px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 6px 22px 20px;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/** Shared language, wrapping, and clipboard controls for code cards. */
|
||||
import {
|
||||
IconCheckOutlineRegular, IconCopyOutlineRegular, IconNowrapFillRegular, IconWrapFillRegular,
|
||||
} from './icons/index.tsx'
|
||||
import { Tooltip } from './Tooltip.tsx'
|
||||
import { supportsHighlighting } from './markdown/highlight.ts'
|
||||
import css from './CodeCard.module.css'
|
||||
|
||||
/** Localized language fallback and wrapping actions supplied by the card owner. */
|
||||
export interface CodeToolbarLabels {
|
||||
/** Title for an absent or unsupported language. */
|
||||
codeLabel: string
|
||||
/** Action that enables wrapping. */
|
||||
wrapLabel: string
|
||||
/** Action that preserves source columns with horizontal scrolling. */
|
||||
unwrapLabel: string
|
||||
}
|
||||
|
||||
/** Display state and callbacks for a code card's toolbar. */
|
||||
interface CodeToolbarProps {
|
||||
lang?: string | undefined
|
||||
title?: string | undefined
|
||||
status?: string | undefined
|
||||
labels: CodeToolbarLabels
|
||||
copyLabel: string
|
||||
copiedLabel: string
|
||||
copied: boolean
|
||||
wrapped: boolean
|
||||
onCopy?: (() => void) | undefined
|
||||
onWrap?: (() => void) | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a language label and keyboard-accessible icon actions with tooltips.
|
||||
* @param props - Localized labels, current state, and card-owned actions.
|
||||
* @returns The shared code-card header.
|
||||
*/
|
||||
export function CodeToolbar({ lang, title, status, labels, copyLabel, copiedLabel, copied, wrapped, onCopy, onWrap }: CodeToolbarProps) {
|
||||
const wrapLabel = wrapped ? labels.unwrapLabel : labels.wrapLabel
|
||||
const clipboardLabel = copied ? copiedLabel : copyLabel
|
||||
return (
|
||||
<div className={css.header} data-code-block-banner>
|
||||
<div className={css.heading}>
|
||||
<span className={css.language}>{supportsHighlighting(lang) ? lang : labels.codeLabel}</span>
|
||||
{title !== undefined && <span className={css.title} title={title}>{title}</span>}
|
||||
</div>
|
||||
<div className={css.actions}>
|
||||
{status !== undefined && <span className={css.status}>{status}</span>}
|
||||
{onWrap !== undefined && <Tooltip label={wrapLabel} side="top" portal>
|
||||
<button type="button" className={css.action} aria-label={labels.wrapLabel} aria-pressed={wrapped} onClick={onWrap}>
|
||||
{wrapped ? <IconNowrapFillRegular size={14} /> : <IconWrapFillRegular size={14} />}
|
||||
</button>
|
||||
</Tooltip>}
|
||||
{onCopy !== undefined && <Tooltip label={clipboardLabel} side="top" portal>
|
||||
<button type="button" className={css.action} aria-label={clipboardLabel} onClick={onCopy}>
|
||||
{copied ? <IconCheckOutlineRegular size={14} /> : <IconCopyOutlineRegular size={14} />}
|
||||
</button>
|
||||
</Tooltip>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +1,58 @@
|
||||
.block {
|
||||
--dsl-diff-radius: 12px;
|
||||
--dsl-diff-line-height: 22px;
|
||||
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-diff-radius);
|
||||
}
|
||||
|
||||
/* The copy control floats in the top-right corner over the body, so the card
|
||||
has no empty banner row above its first diff line (the TUI diff card has no
|
||||
banner either — only the footer). The block is position: relative, so this
|
||||
anchors to the card. */
|
||||
.copyButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
z-index: 1;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xs-13);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 12px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(max-content, 1fr);
|
||||
padding: 6px 0 20px;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* No wrapping, no word-break: a diff is read by its indentation. */
|
||||
.line {
|
||||
min-height: var(--dsl-diff-line-height);
|
||||
box-sizing: border-box;
|
||||
min-height: 1lh;
|
||||
padding: 0 22px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* A file header: the path in the primary tone, set apart by weight. The copy
|
||||
button floats over this first row's top-right corner, so reserve space at the
|
||||
line's end for it — a long path scrolls under the button otherwise, and the
|
||||
button's hit area would eat clicks on the path's tail. */
|
||||
.block[data-code-wrap='true'] .body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.block[data-code-wrap='true'] .line {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.path {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-weight: 600;
|
||||
padding-right: 56px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* A same-file second hunk's separator (a scattered edit), in the dim tone. */
|
||||
.gap {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The diff's own meaning-carrying colors: removed on the error token, added on
|
||||
the success token. A `- `/`+ ` prefix is drawn here so a copied line and the
|
||||
shown line agree, and so the sign reads without relying on color alone. */
|
||||
.del::before {
|
||||
content: '- ';
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.del {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
background: var(--dsw-alias-code-diff-deleted);
|
||||
box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.add::before {
|
||||
content: '+ ';
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.add {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
background: var(--dsw-alias-code-diff-added);
|
||||
box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.context {
|
||||
@@ -87,9 +66,9 @@
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
padding: 0 22px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
@@ -99,11 +78,3 @@
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The change summary, dim under the body: `└ +A -R · N file(s)`, the same
|
||||
footer the TUI transcript's diff card draws. */
|
||||
.footer {
|
||||
padding: 0 14px 12px;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import clsx from 'clsx'
|
||||
import { structuredPatch } from 'diff'
|
||||
import { FoldToggle } from './FoldToggle.tsx'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
import { CodeToolbar, type CodeToolbarLabels } from './CodeToolbar.tsx'
|
||||
import { languageForPath } from './code-highlighting.ts'
|
||||
import cardCss from './CodeCard.module.css'
|
||||
import css from './DiffBlock.module.css'
|
||||
|
||||
/** Output lines shown before the height cap collapses the middle. */
|
||||
@@ -33,14 +36,13 @@ export interface DiffBlockProps {
|
||||
}
|
||||
|
||||
/** Localized chrome for {@link DiffBlock}. */
|
||||
export interface DiffBlockLabels {
|
||||
export interface DiffBlockLabels extends CodeToolbarLabels {
|
||||
copy: string
|
||||
copied: string
|
||||
collapseAria: string
|
||||
expandAria: (hidden: number) => string
|
||||
collapse: string
|
||||
expand: (hidden: number) => string
|
||||
files: (count: number) => string
|
||||
}
|
||||
|
||||
/** A single rendered body line and its role, so the height cap slices a flat list. */
|
||||
@@ -82,7 +84,7 @@ function localHunks(diff: DiffHunk) {
|
||||
* comparisons exceeding the edit limit count both complete fragments as replaced.
|
||||
* Text follows {@link contentLines}'s terminator rule.
|
||||
* @param diffs - the hunks to count.
|
||||
* @returns the +/- totals for summaries and the card footer.
|
||||
* @returns the +/- totals for tool summaries.
|
||||
*/
|
||||
export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number } {
|
||||
let added = 0
|
||||
@@ -99,18 +101,16 @@ export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten local patches into rows and count only added and removed lines.
|
||||
* Flatten local patches into rows.
|
||||
* A path header opens each new file. A `⋯` gap separates consecutive same-file
|
||||
* fragments and distant patches within a fragment. File counts use distinct paths.
|
||||
* fragments and distant patches within a fragment.
|
||||
* @param diffs - the hunks to render.
|
||||
* @returns the body rows, the +/- totals, and the distinct-file count.
|
||||
* @returns the body rows.
|
||||
*/
|
||||
function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
|
||||
function buildRows(diffs: DiffHunk[]): DiffRow[] {
|
||||
const rows: DiffRow[] = []
|
||||
const paths = new Set<string>()
|
||||
let prevPath: string | undefined
|
||||
for (const diff of diffs) {
|
||||
paths.add(diff.path)
|
||||
if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path })
|
||||
else rows.push({ kind: 'gap', text: '⋯' })
|
||||
prevPath = diff.path
|
||||
@@ -122,12 +122,7 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
rows,
|
||||
added: rows.filter(row => row.kind === 'add').length,
|
||||
removed: rows.filter(row => row.kind === 'del').length,
|
||||
files: paths.size,
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,9 +166,12 @@ function copyText(rows: DiffRow[]): string {
|
||||
* @returns the diff block element.
|
||||
*/
|
||||
export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
|
||||
const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
|
||||
const rows = useMemo(() => buildRows(diffs), [diffs])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [wrapped, setWrapped] = useState(false)
|
||||
const firstLanguage = diffs[0] === undefined ? undefined : languageForPath(diffs[0].path)
|
||||
const language = diffs.every(diff => languageForPath(diff.path) === firstLanguage) ? firstLanguage : undefined
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
@@ -198,10 +196,9 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl
|
||||
const tail = capped ? rows.slice(rows.length - tailLines) : []
|
||||
|
||||
return (
|
||||
<div className={clsx(css.block, className)} data-diff="">
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? labels.copied : labels.copy}
|
||||
</button>
|
||||
<div className={clsx(cardCss.card, css.block, className)} data-diff="" data-code-wrap={wrapped}>
|
||||
<CodeToolbar lang={language} labels={labels} copyLabel={labels.copy} copiedLabel={labels.copied}
|
||||
copied={copied} wrapped={wrapped} onCopy={onCopy} onWrap={() => { setWrapped(value => !value) }} />
|
||||
<div className={css.body}>
|
||||
{head.map((row, index) => (
|
||||
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
|
||||
@@ -219,7 +216,6 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl
|
||||
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={css.footer}>└ +{added} -{removed} · {labels.files(files)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +1,37 @@
|
||||
.block {
|
||||
--dsl-read-radius: 12px;
|
||||
--dsl-read-line-height: 22px;
|
||||
/* Fixed-width gutter column for the line numbers, so the content edge stays
|
||||
put down the whole window regardless of how wide the numbers grow. */
|
||||
--dsl-read-gutter: 48px;
|
||||
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-read-radius);
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
border-top-left-radius: var(--dsl-read-radius);
|
||||
border-top-right-radius: var(--dsl-read-radius);
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.lang {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 12px 0;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* One row per file line: a fixed gutter column, then the content. No wrapping —
|
||||
a source line's leading whitespace is meaningful and scrolls sideways. */
|
||||
.line {
|
||||
display: flex;
|
||||
min-height: var(--dsl-read-line-height);
|
||||
line-height: var(--dsl-read-line-height);
|
||||
min-height: 1lh;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
flex: none;
|
||||
width: var(--dsl-read-gutter);
|
||||
padding-right: 14px;
|
||||
margin-right: 12px;
|
||||
text-align: right;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
/* The gutter is chrome, not content: keep it out of a text selection so a
|
||||
copy of the visible rows carries the source, not the line numbers. */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.block[data-code-wrap='true'] .line {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.block[data-code-wrap='true'] .content {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0 0 0 var(--dsl-read-gutter);
|
||||
padding: 0 0 0 calc(var(--dsl-read-gutter) + 12px);
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { FoldToggle } from './FoldToggle.tsx'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
import { CodeToolbar, type CodeToolbarLabels } from './CodeToolbar.tsx'
|
||||
import cardCss from './CodeCard.module.css'
|
||||
import {
|
||||
grammarLoadCount,
|
||||
highlightLines,
|
||||
@@ -44,7 +46,7 @@ export interface ReadBlockProps {
|
||||
}
|
||||
|
||||
/** Localized chrome for {@link ReadBlock}. */
|
||||
export interface ReadBlockLabels {
|
||||
export interface ReadBlockLabels extends CodeToolbarLabels {
|
||||
window: (shown: number, total: number) => string
|
||||
copy: string
|
||||
copied: string
|
||||
@@ -88,6 +90,7 @@ export function ReadBlock({
|
||||
)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [wrapped, setWrapped] = useState(false)
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
@@ -116,27 +119,21 @@ export function ReadBlock({
|
||||
</div>
|
||||
))
|
||||
|
||||
const gutterDigits = lines.reduce((digits, line) => Math.max(digits, String(line.number).length), 3)
|
||||
const gutterStyle = { '--dsl-read-gutter': `${gutterDigits}ch` } as CSSProperties
|
||||
|
||||
const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] =>
|
||||
[line, highlighted?.[index]])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={clsx(css.block, className)} data-read="">
|
||||
<div className={css.banner}>
|
||||
<div className={css.label}>{label ?? ''}</div>
|
||||
<div className={css.action}>
|
||||
{windowed && (
|
||||
<span className={css.count}>{labels.window(lines.length, totalLines)}</span>
|
||||
)}
|
||||
<span className={css.lang}>{lang ?? ''}</span>
|
||||
{/* Empty files omit Copy to avoid replacing the clipboard with an empty string. */}
|
||||
{lines.length > 0 && (
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? labels.copied : labels.copy}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
<div ref={rootRef} className={clsx(cardCss.card, css.block, className)} data-read="" data-code-wrap={wrapped} style={gutterStyle}>
|
||||
<CodeToolbar
|
||||
lang={lang} title={label} status={windowed ? labels.window(lines.length, totalLines) : undefined}
|
||||
labels={labels} copyLabel={labels.copy} copiedLabel={labels.copied} copied={copied} wrapped={wrapped}
|
||||
// Empty files must not replace the clipboard with empty text.
|
||||
onCopy={lines.length > 0 ? onCopy : undefined} onWrap={() => { setWrapped(value => !value) }}
|
||||
/>
|
||||
<div className={cardCss.body}>
|
||||
{rows(capped ? paired.slice(0, headLines) : paired)}
|
||||
{hidden > 0 && (
|
||||
<FoldToggle
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Cordis-free React primitives styled only through `--dsw-*` tokens.
|
||||
*/
|
||||
|
||||
export type { CodeToolbarLabels } from './CodeToolbar.tsx'
|
||||
export { StateDot } from './StateDot.tsx'
|
||||
export type { StateDotState } from './StateDot.tsx'
|
||||
export { DisclosureRow } from './DisclosureRow.tsx'
|
||||
|
||||
@@ -104,6 +104,21 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.card :where(pre) {
|
||||
padding: 6px 22px 20px;
|
||||
word-break: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.card[data-code-wrap='false'] {
|
||||
--dsl-code-block-line-white-space: pre;
|
||||
}
|
||||
|
||||
.card[data-code-wrap='false'] :where(pre) {
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.numbered :where(pre) code {
|
||||
display: block;
|
||||
counter-reset: source-line;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore
|
||||
import type { CSSProperties, ReactNode, Ref } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { writeClipboard } from '../clipboard.ts'
|
||||
import { CodeToolbar, type CodeToolbarLabels } from '../CodeToolbar.tsx'
|
||||
import {
|
||||
StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
|
||||
} from './highlight.ts'
|
||||
@@ -35,6 +36,10 @@ export interface CodeBlockProps {
|
||||
copyLabel: string
|
||||
/** Copy-button label during the post-copy confirmation window. */
|
||||
copiedLabel: string
|
||||
/** Enable the shared card toolbar and spacing; omit for custom toolbar layouts. */
|
||||
toolbarLabels?: CodeToolbarLabels | undefined
|
||||
/** With toolbarLabels, use the owner's wrapping preference and omit the toolbar's local wrap action. */
|
||||
wrap?: boolean | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +69,7 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel,
|
||||
code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, toolbarLabels, wrap,
|
||||
}: CodeBlockProps) {
|
||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||
const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
|
||||
@@ -149,6 +154,8 @@ export function CodeBlock({
|
||||
[streaming, highlighting, streamedBody, trimmed, lang, loaded],
|
||||
)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [localWrapped, setWrapped] = useState(true)
|
||||
const wrapped = wrap ?? localWrapped
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
@@ -178,21 +185,26 @@ export function CodeBlock({
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, className)}
|
||||
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, toolbarLabels !== undefined && css.card, className)}
|
||||
data-line-numbers={lineNumbers || undefined}
|
||||
data-code-wrap={toolbarLabels === undefined ? undefined : wrapped}
|
||||
style={sourceLines === undefined ? undefined : {
|
||||
'--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
|
||||
} as CSSProperties}>
|
||||
{/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
|
||||
{showHeader && <div className={css.bannerWrap}>
|
||||
<div className={css.banner} data-code-block-banner>
|
||||
{toolbarLabels !== undefined ? <CodeToolbar
|
||||
lang={lang} labels={toolbarLabels} copyLabel={copyLabel} copiedLabel={copiedLabel}
|
||||
copied={copied} wrapped={wrapped} onCopy={onCopy}
|
||||
onWrap={wrap === undefined ? () => { setWrapped(value => !value) } : undefined}
|
||||
/> : <div className={css.banner} data-code-block-banner>
|
||||
<div className={css.infostring}>{lang ?? ''}</div>
|
||||
<div className={css.action}>
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? copiedLabel : copyLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>}
|
||||
<div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
|
||||
</div>
|
||||
|
||||
@@ -401,24 +401,6 @@
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
.compact [data-code-block-banner] {
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.compact [data-code-block-banner] > :first-child {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.compact pre {
|
||||
margin: 4px 0;
|
||||
padding: 8px;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* Shiki writes token colors inline; compact code uses the surrounding text color. */
|
||||
.compact pre,
|
||||
.compact pre :is(code, span) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import clsx from 'clsx'
|
||||
import type * as Md from 'mdast'
|
||||
import type {} from 'mdast-util-math'
|
||||
import { normalizeUri } from 'micromark-util-sanitize-uri'
|
||||
import type { CodeToolbarLabels } from '../CodeToolbar.tsx'
|
||||
import { CodeBlock } from './CodeBlock.tsx'
|
||||
import { parseFileLink } from './file-link.ts'
|
||||
import { renderTexToReact } from './katex.tsx'
|
||||
@@ -36,6 +37,8 @@ export interface MarkdownCodeLabels {
|
||||
copyLabel: string
|
||||
/** Copy-button label during the post-copy confirmation window. */
|
||||
copiedLabel: string
|
||||
/** Shared card controls; omitted for custom toolbar layouts. */
|
||||
toolbarLabels?: CodeToolbarLabels | undefined
|
||||
}
|
||||
|
||||
/** Localized chrome for a Markdown document. */
|
||||
@@ -399,6 +402,7 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
|
||||
streaming={context.streaming}
|
||||
copyLabel={context.labels.code.copyLabel}
|
||||
copiedLabel={context.labels.code.copiedLabel}
|
||||
toolbarLabels={context.labels.code.toolbarLabels}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
|
||||
import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
|
||||
import { ReadBlock } from '../src/ReadBlock.tsx'
|
||||
import { DiffBlock } from '../src/DiffBlock.tsx'
|
||||
import { diffBlockLabels, readBlockLabels } from './labels.client.ts'
|
||||
|
||||
const toolbarLabels = { codeLabel: 'Code block', wrapLabel: 'Wrap lines', unwrapLabel: 'Do not wrap lines' }
|
||||
const labels = { copyLabel: 'Copy', copiedLabel: 'Copied', toolbarLabels }
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('code-card controls', () => {
|
||||
it.each([undefined, 'unregistered'])('labels the %s language with the localized code title', (lang) => {
|
||||
render(<CodeBlock code="plain source" lang={lang} {...labels} />)
|
||||
expect(screen.getByText('Code block')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('')
|
||||
})
|
||||
|
||||
it('keeps a known grammar name while its highlighter is not yet visible', () => {
|
||||
render(<CodeBlock code="const x = 1" lang="typescript" {...labels} />)
|
||||
expect(screen.getByText('typescript')).toBeTruthy()
|
||||
expect(screen.queryByText('Code block')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['code', 'read', 'diff'] as const)('toggles %s wrapping without changing source text', (kind) => {
|
||||
const code = 'const longName = "a long value whose source must stay intact"'
|
||||
const view = render(kind === 'code'
|
||||
? <CodeBlock code={code} lang="ts" {...labels} />
|
||||
: kind === 'read'
|
||||
? <ReadBlock lines={[{ number: 24, text: code }]} totalLines={30} lang="ts" labels={{ ...readBlockLabels, ...toolbarLabels }} />
|
||||
: <DiffBlock diffs={[{ path: 'example.ts', oldText: 'old', newText: code }]} labels={{ ...diffBlockLabels, ...toolbarLabels }} />)
|
||||
const button = screen.getByRole('button', { name: 'Wrap lines' })
|
||||
const wasWrapped = button.getAttribute('aria-pressed') === 'true'
|
||||
fireEvent.click(button)
|
||||
expect(button.getAttribute('aria-pressed')).toBe(String(!wasWrapped))
|
||||
expect(button.getAttribute('aria-label')).toBe('Wrap lines')
|
||||
expect(view.container.textContent).toContain(code)
|
||||
fireEvent.click(button)
|
||||
expect(button.getAttribute('aria-pressed')).toBe(String(wasWrapped))
|
||||
})
|
||||
|
||||
it('shows hover tooltips for copying and wrapping and updates the wrapping action', () => {
|
||||
render(<CodeBlock code="source" {...labels} />)
|
||||
const copy = screen.getByRole('button', { name: 'Copy' })
|
||||
fireEvent.mouseEnter(copy)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Copy')
|
||||
fireEvent.mouseLeave(copy)
|
||||
const wrap = screen.getByRole('button', { name: 'Wrap lines' })
|
||||
fireEvent.mouseEnter(wrap)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Do not wrap lines')
|
||||
fireEvent.click(wrap)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.mouseEnter(wrap)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Wrap lines')
|
||||
})
|
||||
|
||||
it('copies source through the icon and announces success', async () => {
|
||||
vi.useFakeTimers()
|
||||
const original = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
const writeText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue()
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
try {
|
||||
render(<CodeBlock code={'const x = 1\n'} {...labels} />)
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Copy' })) })
|
||||
expect(writeText).toHaveBeenCalledWith('const x = 1')
|
||||
expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy()
|
||||
} finally {
|
||||
if (original === undefined) Reflect.deleteProperty(navigator, 'clipboard')
|
||||
else Object.defineProperty(navigator, 'clipboard', original)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows compact Markdown icon actions and preserves custom source controls', () => {
|
||||
const view = render(<MarkdownText text={'```ts\nconst x = 1\n```'} variant="compact" labels={{ code: labels, footnotes: 'Footnotes' }} />)
|
||||
const wrap = screen.getByRole('button', { name: 'Wrap lines' })
|
||||
expect(wrap.getAttribute('aria-pressed')).toBe('true')
|
||||
fireEvent.click(wrap)
|
||||
expect(wrap.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('')
|
||||
view.rerender(<CodeBlock code="source" copyLabel="Copy" copiedLabel="Copied" />)
|
||||
expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('Copy')
|
||||
})
|
||||
|
||||
it('uses the fallback title for diffs containing different languages', () => {
|
||||
render(<DiffBlock diffs={[
|
||||
{ path: 'example.ts', oldText: '', newText: 'export {}' },
|
||||
{ path: 'example.py', oldText: '', newText: 'print(1)' },
|
||||
]} labels={{ ...diffBlockLabels, ...toolbarLabels }} />)
|
||||
expect(screen.getByText('Code block')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -75,18 +75,15 @@ describe('DiffBlock structure', () => {
|
||||
})
|
||||
|
||||
it('treats a trailing newline as a terminator, not an extra blank line', () => {
|
||||
// A create whose newText ends in a newline is one added line, not two, and
|
||||
// the footer counts one — the phantom `+ ` empty line the naive split drew.
|
||||
const { container } = render(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
|
||||
expect(changeRows(container)).toEqual(['hello'])
|
||||
expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a full deletion as removed-only with no phantom added line', () => {
|
||||
// newText '' is zero added lines: an empty string must contribute nothing.
|
||||
const { container } = render(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
|
||||
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0)
|
||||
expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy()
|
||||
expect(changeRows(container)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps a genuine interior blank line', () => {
|
||||
@@ -105,7 +102,6 @@ describe('DiffBlock local changes', () => {
|
||||
const total = count === 128 ? count : count + 1
|
||||
render(<DiffBlock diffs={diffs} maxLines={1000} />)
|
||||
expect(diffTotals(diffs)).toEqual({ added: total, removed: total })
|
||||
expect(screen.getByText(`└ +${total} -${total} · 1 file`)).toBeTruthy()
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
|
||||
expect(writeText).toHaveBeenCalledWith(count === 128
|
||||
? ['large.txt', ' shared context', ...oldLines.slice(1).map(line => `- ${line}`), ...newLines.slice(1).map(line => `+ ${line}`)].join('\n')
|
||||
@@ -132,7 +128,6 @@ describe('DiffBlock local changes', () => {
|
||||
render(<DiffBlock diffs={diffs} />)
|
||||
expect(screen.getAllByText('start')).toHaveLength(1)
|
||||
expect(screen.getAllByText('end')).toHaveLength(1)
|
||||
expect(screen.getByText('└ +1 -1 · 1 file')).toBeTruthy()
|
||||
expect(diffTotals(diffs)).toEqual({ added: 1, removed: 1 })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
|
||||
expect(writeText).toHaveBeenCalledWith('settings.ts\n start\n- mode = 1\n+ mode = 2\n end')
|
||||
@@ -150,7 +145,6 @@ describe('DiffBlock local changes', () => {
|
||||
'item 40', 'changed 40', 'item 41', 'item 42', 'item 43',
|
||||
])
|
||||
expect(changeRows(container)).toEqual(['item 10', 'changed 10', 'item 40', 'changed 40'])
|
||||
expect(screen.getByText('└ +2 -2 · 1 file')).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -163,26 +157,10 @@ describe('DiffBlock local changes', () => {
|
||||
['a\n\nb', 'a\nb', 0, 1],
|
||||
])('counts ordered changes in %j → %j', (oldText, newText, added, removed) => {
|
||||
const diffs = [{ path: 'a.txt', oldText, newText }]
|
||||
render(<DiffBlock diffs={diffs} />)
|
||||
const { container } = render(<DiffBlock diffs={diffs} />)
|
||||
expect(diffTotals(diffs)).toEqual({ added, removed })
|
||||
expect(screen.getByText(`└ +${added} -${removed} · 1 file`)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DiffBlock footer', () => {
|
||||
it('counts added and removed lines and one file', () => {
|
||||
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }]
|
||||
render(<DiffBlock diffs={diffs} />)
|
||||
expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pluralizes the distinct-file count', () => {
|
||||
const diffs: DiffHunk[] = [
|
||||
{ path: 'a.ts', oldText: null, newText: 'x' },
|
||||
{ path: 'b.ts', oldText: null, newText: 'y' },
|
||||
]
|
||||
render(<DiffBlock diffs={diffs} />)
|
||||
expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy()
|
||||
expect(container.querySelectorAll('[class*="_add_"]')).toHaveLength(added)
|
||||
expect(container.querySelectorAll('[class*="_del_"]')).toHaveLength(removed)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -14,13 +14,14 @@ export const markdownLabels: MarkdownLabels = {
|
||||
}
|
||||
|
||||
export const diffBlockLabels: DiffBlockLabels = {
|
||||
codeLabel: '代码块', wrapLabel: '自动换行', unwrapLabel: '取消自动换行',
|
||||
copy: '复制', copied: '复制成功', collapseAria: '收起差异',
|
||||
expandAria: hidden => `展开其余 ${hidden} 行差异`,
|
||||
collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`,
|
||||
files: count => `${count} ${count === 1 ? 'file' : 'files'}`,
|
||||
}
|
||||
|
||||
export const readBlockLabels: ReadBlockLabels = {
|
||||
codeLabel: '代码块', wrapLabel: '自动换行', unwrapLabel: '取消自动换行',
|
||||
window: (shown, total) => `显示 ${shown} / ${total} 行`,
|
||||
copy: '复制', copied: '复制成功', collapseAria: '收起内容',
|
||||
expandAria: hidden => `展开其余 ${hidden} 行`,
|
||||
|
||||
@@ -134,10 +134,9 @@ describe('ReadBlock banner', () => {
|
||||
expect(view.queryByText(/显示/u)).toBeNull()
|
||||
})
|
||||
|
||||
it('draws an empty label and empty language when neither is given', () => {
|
||||
const view = render(<ReadBlock lines={lines(1)} totalLines={1} />)
|
||||
expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
|
||||
expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
|
||||
it('uses the localized code title when no language is given', () => {
|
||||
render(<ReadBlock lines={lines(1)} totalLines={1} />)
|
||||
expect(screen.getByText('代码块')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-browser/README.md
|
||||
README.md: 52ebeddd088a8427b1bf058442b6fab6a768de37
|
||||
README.zh.md: 93df01dbe13bfa4a5aa318a1fd3a9e9786cd3c67
|
||||
README.md: 3fc359efc0b26ecfa006f92a094e6408781c2155
|
||||
README.zh.md: b3a452b0e53695ef08aa5ceced432d2710c170e3
|
||||
@@ -25,7 +25,7 @@ Browse HTTP(S) pages, including loopback services, inside independent right-Side
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Browser is disabled by default in Web profiles and enabled on Desktop. Enable the shipped entry through the Web profile patch to use it. Open **Browser** from the right-Sidebar guide and enter an HTTP(S) URL. Chat HTTP(S) links open here when the [link preference](../ui-chat/README.md) selects the built-in browser. A host name without a scheme becomes HTTPS. Public and loopback targets use the same default sandbox. Each guide action or delegated message-link activation creates another Browser tab.
|
||||
Browser is disabled by default in Web profiles and enabled on Desktop. Enable the shipped entry through the Web profile patch to use it. Open **Browser** from the right-Sidebar guide and enter an HTTP(S) URL. Chat HTTP(S) links open here when the [link preference](../ui-chat/README.md) selects **In-App Sidebar**. A host name without a scheme becomes HTTPS. Public and loopback targets use the same default sandbox. Each guide action or delegated message-link activation creates another Browser tab.
|
||||
|
||||
### When to choose it
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
Browser 在 Web profile 中默认禁用,在 Desktop 中默认启用。Web 用户可通过 profile patch 启用随附条目。可以从右侧 Sidebar guide 打开 **浏览器**并输入 HTTP(S) URL。Chat 的[链接偏好](../ui-chat/README.zh.md)选择内置浏览器时,HTTP(S) 链接会在此打开。不带 scheme 的主机名会补全为 HTTPS。公共目标与 loopback 目标使用相同的默认 sandbox。每次 guide 操作或委托到此的消息链接操作都会创建一个新的 Browser tab。
|
||||
Browser 在 Web profile 中默认禁用,在 Desktop 中默认启用。Web 用户可通过 profile patch 启用随附条目。可以从右侧 Sidebar guide 打开 **浏览器**并输入 HTTP(S) URL。Chat 的[链接偏好](../ui-chat/README.zh.md)选择**应用内侧边栏**时,HTTP(S) 链接会在此打开。不带 scheme 的主机名会补全为 HTTPS。公共目标与 loopback 目标使用相同的默认 sandbox。每次 guide 操作或委托到此的消息链接操作都会创建一个新的 Browser tab。
|
||||
|
||||
### 何时选择
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md
|
||||
README.md: 224957fec45fd4d2849df677b9cada62d01d18cd
|
||||
README.zh.md: 18fe9e94617291d85fd3db7bf1c7bcc347f6ec73
|
||||
README.md: 5507bd89f07bfd6225d4ecc1bc4500ceb2e30fe8
|
||||
README.zh.md: eec96c28654ae01aca6b89862d503af6c12296e8
|
||||
@@ -61,12 +61,12 @@ 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 graphite 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.
|
||||
|
||||
Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. 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 pages fill the pane's width edge to edge as one vertical sequence and 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 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, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. 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 pages fill the pane's width edge to edge as one vertical sequence and 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.
|
||||
|
||||
<a id="excel-preview"></a>
|
||||
## Excel preview
|
||||
|
||||
Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. Spreadsheet preview does not call the Office conversion service.
|
||||
Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The workbook viewport follows preview pane size changes without reloading its sheets or selection. Spreadsheet preview does not call the Office conversion service.
|
||||
|
||||
XLSX preview omits DrawingML parts from an in-memory copy before parsing and ignores worksheet drawing references. The original file and worksheet XML remain unchanged. A notice above the table lists detected charts, images, shapes, and conditional formatting that are not displayed, and recommends opening the workbook in a system application. Files without these detected features have no notice; formula warnings remain separate.
|
||||
|
||||
|
||||
@@ -61,12 +61,12 @@ 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 预览不显示缩放控件。
|
||||
|
||||
首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
|
||||
首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 tertiary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
|
||||
|
||||
<a id="excel-preview"></a>
|
||||
## Excel 预览
|
||||
|
||||
直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。表格预览不调用 Office 转换服务。
|
||||
直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。工作簿视图随预览面板尺寸变化自动调整,无需重新加载工作表或选择状态。表格预览不调用 Office 转换服务。
|
||||
|
||||
XLSX 预览在解析前从内存副本中移除 DrawingML 部件,并忽略工作表的绘图引用。原文件和工作表 XML 保持不变。表格上方的提示列出实际检测到但未展示的图表、图片、形状和条件格式,并建议使用系统应用打开工作簿。未检测到这些内容的文件不显示此提示;公式提示保持独立。
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
|
||||
.renderer .code pre {
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
min-width: 100%;
|
||||
overflow: visible;
|
||||
white-space: pre;
|
||||
|
||||
@@ -24,9 +24,11 @@ export function CodeBody({ resourceAddress, content, wrap, scrollportRef, t }: C
|
||||
code={content.text}
|
||||
lang={language}
|
||||
streaming={!content.eof}
|
||||
wrap={wrap}
|
||||
lineNumbers
|
||||
copyLabel={t('copy')}
|
||||
copiedLabel={t('copied')}
|
||||
toolbarLabels={{ codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Read-only spreadsheet surface backed by browser-parsed workbook data. */
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Workbook } from '@fortune-sheet/react'
|
||||
import { Button, IconWarningTriangleOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import fortuneCss from '@fortune-sheet/react/dist/index.css?inline'
|
||||
@@ -14,6 +14,7 @@ const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }`
|
||||
|
||||
/**
|
||||
* Display stored spreadsheet values and formats without editing or recalculation.
|
||||
* Keep the mounted workbook sized to its preview pane.
|
||||
* @param props - Complete workbook bytes, limits, and locale.
|
||||
* @returns An isolated spreadsheet surface with cancellable loading.
|
||||
*/
|
||||
@@ -21,6 +22,7 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
|
||||
const data = content.kind === 'bytes' ? content.data : undefined
|
||||
const [state, setState] = useState<State>()
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const workbookRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (data === undefined) return
|
||||
const controller = new AbortController()
|
||||
@@ -31,6 +33,14 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
|
||||
)
|
||||
return () => { controller.abort() }
|
||||
}, [data, format, limits, attempt])
|
||||
useEffect(() => {
|
||||
const element = workbookRef.current
|
||||
if (element === null || typeof ResizeObserver === 'undefined') return
|
||||
// FortuneSheet measures its canvas on window resize, but pane resizing does not emit that event.
|
||||
const observer = new ResizeObserver(() => { window.dispatchEvent(new Event('resize')) })
|
||||
observer.observe(element)
|
||||
return () => { observer.disconnect() }
|
||||
}, [state])
|
||||
if (data === undefined) return <p className={css.status} role="alert">{t('invalid')}</p>
|
||||
if (state?.data !== data || state.format !== format) return <span className={css.status} role="status" aria-label={t('loading')} data-document-loading>
|
||||
<StateDot state="ongoing" />
|
||||
@@ -46,7 +56,7 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
|
||||
<IconWarningTriangleOutlineRegular size={16} />
|
||||
<span>{t('unsupportedNotice', { features: state.value.unsupportedFeatures.map(feature => t(feature)).join(t('featureSeparator')) })}</span>
|
||||
</div>}
|
||||
<div className={`${css.workbook} ${hasFormulas ? css.withFormulaWarning : ''}`}>
|
||||
<div ref={workbookRef} className={`${css.workbook} ${hasFormulas ? css.withFormulaWarning : ''}`}>
|
||||
{hasFormulas && <Tooltip portal label={t('formulaWarning')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.formulaWarning} aria-label={t('formulaWarning')} data-excel-formula-warning>
|
||||
<IconWarningTriangleOutlineRegular size={14} />
|
||||
|
||||
@@ -24,9 +24,12 @@ export function MarkdownBody({ content, resourceAddress, useResource, t }: Markd
|
||||
const copyLabel = t('code.copy')
|
||||
const copiedLabel = t('code.copied')
|
||||
const footnotes = t('footnotes')
|
||||
const codeLabel = t('codeBlock.title')
|
||||
const wrapLabel = t('codeBlock.wrap')
|
||||
const unwrapLabel = t('codeBlock.unwrap')
|
||||
const labels = useMemo<MarkdownLabels>(() => ({
|
||||
code: { copyLabel, copiedLabel }, footnotes,
|
||||
}), [copyLabel, copiedLabel, footnotes])
|
||||
code: { copyLabel, copiedLabel, toolbarLabels: { codeLabel, wrapLabel, unwrapLabel } }, footnotes,
|
||||
}), [copyLabel, copiedLabel, footnotes, codeLabel, wrapLabel, unwrapLabel])
|
||||
if (content.kind !== 'text') return null
|
||||
return (
|
||||
<div className={css.document} data-document-markdown>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { CodeBody } from '../src/client/code/CodeBody.tsx'
|
||||
@@ -84,7 +84,11 @@ describe('CodeBody', () => {
|
||||
expect(view.getByText(language, { exact: true })).toBeTruthy()
|
||||
expect(element(view.container, '.shiki').textContent).toBe('const answer = 42')
|
||||
expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
|
||||
expect(view.getByRole('button', { name: 'Copy' })).toBeTruthy()
|
||||
const copy = view.getByRole('button', { name: 'Copy' })
|
||||
expect(copy.textContent).toBe('')
|
||||
expect(view.getAllByRole('button')).toHaveLength(1)
|
||||
fireEvent.mouseEnter(copy)
|
||||
expect(view.getByRole('tooltip').textContent).toBe('Copy')
|
||||
})
|
||||
|
||||
it('carries multiline grammar state across pages and preserves completed lines through settlement', () => {
|
||||
@@ -155,6 +159,7 @@ describe('CodeBody', () => {
|
||||
const view = render(<CodeBody {...props(content)} />)
|
||||
const pre = element(view.container, '.shiki')
|
||||
const block = element(view.container, '.md-code-block')
|
||||
expect(block.getAttribute('data-code-wrap')).toBe('false')
|
||||
const scrollport = element(view.container, '[data-code-block-content]')
|
||||
expect(getComputedStyle(block).marginTop).toBe('0px')
|
||||
expect(getComputedStyle(block).position).toBe('static')
|
||||
@@ -165,6 +170,8 @@ describe('CodeBody', () => {
|
||||
expect(getComputedStyle(pre).overflow).toBe('visible')
|
||||
expect(getComputedStyle(pre).wordBreak).toBe('normal')
|
||||
view.rerender(<CodeBody {...props(content, { wrap: true })} />)
|
||||
expect(block.getAttribute('data-code-wrap')).toBe('true')
|
||||
expect(view.getAllByRole('button')).toHaveLength(1)
|
||||
expect(element(view.container, '.shiki')).toBe(pre)
|
||||
expect(element(view.container, '[data-code-block-content]')).toBe(scrollport)
|
||||
expect(getComputedStyle(pre).whiteSpace).toBe('pre-wrap')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Excel preview lifecycle and read-only renderer settings. */
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { Config } from '../src/config.ts'
|
||||
@@ -17,7 +17,31 @@ const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: C
|
||||
const loadedProps = { ...props, format: 'xlsx' } as LoadedExcelBodyProps
|
||||
const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0, unsupportedFeatures: [] }
|
||||
const formulaValue = { sheets: [{ name: 'Budget', celldata: [{ r: 0, c: 0, v: { f: '=SUM(1,2)', m: '' } }] }], missingResults: 1, unsupportedFeatures: [] }
|
||||
afterEach(() => { cleanup(); vi.resetAllMocks() })
|
||||
afterEach(() => { cleanup(); vi.resetAllMocks(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||||
|
||||
it('refreshes the existing workbook on pane resize and disconnects on file replacement and unmount', async () => {
|
||||
const observers: { callback: ResizeObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = []
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
constructor(callback: ResizeObserverCallback) { observers.push({ callback, observe: this.observe, disconnect: this.disconnect }) }
|
||||
})
|
||||
const dispatch = vi.spyOn(window, 'dispatchEvent')
|
||||
mocked.parse.mockResolvedValue(value)
|
||||
const view = render(<ExcelBody {...loadedProps} />)
|
||||
await waitFor(() => { expect(observers).toHaveLength(1) })
|
||||
const first = observers[0]!
|
||||
expect(first.observe).toHaveBeenCalledWith(view.container.querySelector('[data-excel-preview] > div'))
|
||||
act(() => { first.callback([], {} as ResizeObserver) })
|
||||
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'resize' }))
|
||||
expect(mocked.workbook).toHaveBeenCalledOnce()
|
||||
expect(mocked.parse).toHaveBeenCalledOnce()
|
||||
view.rerender(<ExcelBody {...loadedProps} content={{ kind: 'bytes', data: new Uint8Array([2]) }} />)
|
||||
await waitFor(() => { expect(observers).toHaveLength(2) })
|
||||
expect(first.disconnect).toHaveBeenCalledOnce()
|
||||
view.unmount()
|
||||
expect(observers[1]!.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows loading then a workbook with editing and recalculation disabled', async () => {
|
||||
mocked.parse.mockResolvedValue(formulaValue)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
|
||||
README.md: 3961c16f862ed83d8b64b1a899f24f254e99ee63
|
||||
README.zh.md: 22fc05f7a82fe549cb8e77a2772b62bfd8b15baa
|
||||
README.md: f8db4c3c353aa9b6854c05288316babd198475d7
|
||||
README.zh.md: 135878213908ea11093d267c361cf2f80b4d768a
|
||||
@@ -51,7 +51,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou
|
||||
|
||||
### Stylesheets
|
||||
|
||||
`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.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.
|
||||
`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.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; `shiki.css` owns syntax colors.
|
||||
|
||||
`brand-font.css` exports the local Montserrat Light, Regular and Medium faces (normal style, weights 300, 400 and 500), with `montserrat-light.woff2`, `montserrat-regular.woff2`, `montserrat-medium.woff2` and its SIL Open Font License in `lib/styles/`. Desktop bundles the same stylesheet, font and license for offline welcome brand text; ordinary UI keeps its system font stack.
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ kind: "package-reference"
|
||||
|
||||
### 样式表
|
||||
|
||||
`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。
|
||||
`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.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` 还负责代码差异底色的别名及其静态透明度色阶;`shiki.css` 负责语法颜色。
|
||||
|
||||
`brand-font.css` 导出本地 Montserrat Light、Regular 和 Medium 字体(正体、字重 300、400 和 500),`lib/styles/` 同时提供 `montserrat-light.woff2`、`montserrat-regular.woff2`、`montserrat-medium.woff2` 及其 SIL Open Font License。Desktop 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ body {
|
||||
--dsw-static-green-100: rgb(230, 250, 237);
|
||||
--dsw-static-green-400: rgb(78, 209, 126);
|
||||
--dsw-static-green-500: rgb(34, 197, 94);
|
||||
--dsw-static-green-500-a08: rgb(34 197 94 / 8%);
|
||||
--dsw-static-green-500-a12: rgb(34 197 94 / 12%);
|
||||
--dsw-static-green-900: rgb(35, 60, 44);
|
||||
--dsw-static-neutral-00: rgb(255, 255, 255);
|
||||
--dsw-static-neutral-1000: rgb(0, 0, 0);
|
||||
@@ -72,9 +74,11 @@ body {
|
||||
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
|
||||
--dsw-static-red-100: rgb(254, 226, 226);
|
||||
--dsw-static-red-400: rgb(242, 90, 90);
|
||||
--dsw-static-red-400-a12: rgb(242 90 90 / 12%);
|
||||
--dsw-static-red-500: rgb(239, 68, 68);
|
||||
--dsw-static-red-50: rgb(254, 242, 242);
|
||||
--dsw-static-red-600: rgb(236, 19, 19);
|
||||
--dsw-static-red-600-a08: rgb(236 19 19 / 8%);
|
||||
--dsw-static-red-900: rgb(87, 12, 12);
|
||||
}
|
||||
|
||||
@@ -111,6 +115,8 @@ body[data-ds-dark-theme] {
|
||||
--dsw-static-green-100: rgb(230, 250, 237);
|
||||
--dsw-static-green-400: rgb(78, 209, 126);
|
||||
--dsw-static-green-500: rgb(34, 197, 94);
|
||||
--dsw-static-green-500-a08: rgb(34 197 94 / 8%);
|
||||
--dsw-static-green-500-a12: rgb(34 197 94 / 12%);
|
||||
--dsw-static-green-900: rgb(35, 60, 44);
|
||||
--dsw-static-neutral-00: rgb(255, 255, 255);
|
||||
--dsw-static-neutral-1000: rgb(0, 0, 0);
|
||||
@@ -149,9 +155,11 @@ body[data-ds-dark-theme] {
|
||||
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
|
||||
--dsw-static-red-100: rgb(254, 226, 226);
|
||||
--dsw-static-red-400: rgb(242, 90, 90);
|
||||
--dsw-static-red-400-a12: rgb(242 90 90 / 12%);
|
||||
--dsw-static-red-500: rgb(239, 68, 68);
|
||||
--dsw-static-red-50: rgb(254, 242, 242);
|
||||
--dsw-static-red-600: rgb(236, 19, 19);
|
||||
--dsw-static-red-600-a08: rgb(236 19 19 / 8%);
|
||||
--dsw-static-red-900: rgb(87, 12, 12);
|
||||
}
|
||||
|
||||
@@ -226,6 +234,8 @@ body {
|
||||
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-300);
|
||||
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-500);
|
||||
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-100);
|
||||
--dsw-alias-code-diff-added: var(--dsw-static-green-500-a08);
|
||||
--dsw-alias-code-diff-deleted: var(--dsw-static-red-600-a08);
|
||||
--dsw-alias-state-error-primary: var(--dsw-static-red-600);
|
||||
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
|
||||
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-300);
|
||||
@@ -324,6 +334,8 @@ body[data-ds-dark-theme] {
|
||||
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550);
|
||||
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-400);
|
||||
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800);
|
||||
--dsw-alias-code-diff-added: var(--dsw-static-green-500-a12);
|
||||
--dsw-alias-code-diff-deleted: var(--dsw-static-red-400-a12);
|
||||
--dsw-alias-state-error-primary: var(--dsw-static-red-400);
|
||||
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
|
||||
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-600);
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
|
||||
README.md: e26a277028d41420c4796b4678a2d6897d592872
|
||||
README.zh.md: 63d34c640571b0b3e363f6968221d15eea70ad49
|
||||
README.md: 622dbd89c93f8a21bece72f2267613a4fa81b4d6
|
||||
README.zh.md: c3fec14cb7a5d2e2448743204b59dcac73380849
|
||||
@@ -68,7 +68,7 @@ Tool owner props forward Chat's stable `useDisclosure` Hook through root and nes
|
||||
|
||||
Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules.
|
||||
|
||||
Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The collapsed row and expanded footer share the primitive's exact or coarse-replacement counts.
|
||||
Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer.
|
||||
|
||||
An Auto denial takes precedence over keyed specialized views. Its generic row preserves the call identity, omits raw arguments, and normalizes the stored reason only for display: trim surrounding whitespace and collapse line separators to spaces, with localized fallback for an empty result. Session and SDK error details keep the original reason.
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调
|
||||
|
||||
每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。
|
||||
|
||||
Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。折叠工具行与展开卡片底部采用原语一致的精确或粗粒度替换统计。
|
||||
Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。
|
||||
|
||||
Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。
|
||||
|
||||
|
||||
@@ -232,15 +232,13 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.root .item:has(> .code:only-child) { padding: 0; }
|
||||
.root .item > .code:only-child { margin: 0; }
|
||||
|
||||
.root .group .item:has(> .code:only-child) {
|
||||
padding: 4px 0 0 20px;
|
||||
}
|
||||
|
||||
.root .group .item > .code:only-child { margin: 0; }
|
||||
|
||||
.root .code [data-code-block-banner] { padding: 4px 10px; }
|
||||
.root .code pre { padding: 6px 10px 8px; }
|
||||
|
||||
.path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { OpenFileOptions } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { markdownLabels } from '../models/primitive-labels.ts'
|
||||
import { codeToolbarLabels, markdownLabels } from '../models/primitive-labels.ts'
|
||||
import css from './ToolDetails.module.css'
|
||||
|
||||
/** One recorded entity, receipt, or collapsible group of related values. */
|
||||
@@ -89,6 +89,7 @@ function DetailItem({ item, t, onOpenFile }: { item: ToolDetailItem } & DetailCo
|
||||
{item.markdown !== undefined && <div className={css.prose}><MarkdownText text={item.markdown} labels={markdownLabels(t)} /></div>}
|
||||
{item.code !== undefined && (
|
||||
<CodeBlock
|
||||
toolbarLabels={codeToolbarLabels(t)}
|
||||
className={css.code} code={item.code.text} lang={item.code.language}
|
||||
copyLabel={t('copy')} copiedLabel={t('copied')}
|
||||
/>
|
||||
|
||||
@@ -324,12 +324,6 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* In-row code renders at the smaller code size (12/18) via each primitive's
|
||||
rebindable content-font seam; standalone markdown code blocks keep 13/22. */
|
||||
.codeBody {
|
||||
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
/* The terminal card scrolls its OUTPUT inside its own surface (same l1
|
||||
hairline as the IN/OUT card): the banner stays pinned and the scrollbar
|
||||
never rides over it. 224px = the 260px card cap minus the ~36px banner. */
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
localizeTerminalCardModel, terminalBlockLabels, type TerminalCardModel,
|
||||
} from '../models/terminal-card-model.ts'
|
||||
import {
|
||||
diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
|
||||
codeToolbarLabels, diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
|
||||
} from '../models/primitive-labels.ts'
|
||||
import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts'
|
||||
import {
|
||||
@@ -171,9 +171,8 @@ export const ToolRow = memo(function ToolRow({
|
||||
// amber while retaining the business icon and hidden state announcement.
|
||||
const failureLine = state === 'error' ? errorSummary ?? normalSummary : null
|
||||
const summaryText = failureLine ?? normalSummary
|
||||
// A diff row's collapsed line carries the card's +/- totals (the same
|
||||
// numbers the expanded footer prints) so the change size reads without
|
||||
// expanding; an explicit summarySuffix (none today on diff rows) wins.
|
||||
// The tool row keeps the diff's +/- totals visible while its body is collapsed.
|
||||
// An explicit summarySuffix overrides the diff totals.
|
||||
const diffStat = useMemo(() => {
|
||||
if (diffBody === null) return null
|
||||
const { added, removed } = diffTotals(diffBody.card.diffs)
|
||||
@@ -289,7 +288,8 @@ export const ToolRow = memo(function ToolRow({
|
||||
<>
|
||||
{variant === 'code' && bodyText !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')}
|
||||
toolbarLabels={codeToolbarLabels(t)} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Localized copy adapters for Cordis-free UI primitives used by Tool cards. */
|
||||
|
||||
import type {
|
||||
CodeToolbarLabels,
|
||||
DiffBlockLabels,
|
||||
MarkdownLabels,
|
||||
ReadBlockLabels,
|
||||
@@ -11,6 +12,15 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
type T = TranslateNS<'conversation'>
|
||||
|
||||
/**
|
||||
* Localize the shared code-card toolbar.
|
||||
* @param t - Conversation locale seat.
|
||||
* @returns Language fallback and wrapping actions.
|
||||
*/
|
||||
export function codeToolbarLabels(t: T): CodeToolbarLabels {
|
||||
return { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build localized Markdown chrome labels.
|
||||
* @param t - Conversation locale seat.
|
||||
@@ -18,7 +28,7 @@ type T = TranslateNS<'conversation'>
|
||||
*/
|
||||
export function markdownLabels(t: T): MarkdownLabels {
|
||||
return {
|
||||
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
|
||||
code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: codeToolbarLabels(t) },
|
||||
footnotes: t('markdown.footnotes'),
|
||||
}
|
||||
}
|
||||
@@ -30,13 +40,13 @@ export function markdownLabels(t: T): MarkdownLabels {
|
||||
*/
|
||||
export function diffBlockLabels(t: T): DiffBlockLabels {
|
||||
return {
|
||||
...codeToolbarLabels(t),
|
||||
copy: t('copy'),
|
||||
copied: t('copied'),
|
||||
collapseAria: t('diff.collapseAria'),
|
||||
expandAria: count => t('diff.expandAria', { count }),
|
||||
collapse: t('collapse'),
|
||||
expand: count => t('diff.expandRest', { count }),
|
||||
files: count => t(count === 1 ? 'diff.files.one' : 'diff.files.other', { count }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +57,7 @@ export function diffBlockLabels(t: T): DiffBlockLabels {
|
||||
*/
|
||||
export function readBlockLabels(t: T): ReadBlockLabels {
|
||||
return {
|
||||
...codeToolbarLabels(t),
|
||||
window: (shown, total) => t('read.window', { shown, total }),
|
||||
copy: t('copy'),
|
||||
copied: t('copied'),
|
||||
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user