mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(qwen-live): scaffold standalone live daemon package with ported host stack New workspace package @qwen-code/qwen-live (M1+M2 of the Live split, issue #10118). This commit adds the package skeleton and the verbatim ports that need no behavioral changes: - host/: Live Host protocol v6 types, LiveHostCoordinator, and the discovery-file writer, ported from packages/cli/src/serve/live/ together with their test suites (51 tests green) - realtime/socket.ts, realtime/sanitize.ts: the two helper symbols the realtime client depends on, extracted from cli ui utils - registered in scripts/build.js buildOrder and root vitest projects * feat(qwen-live): generalize the realtime client tool surface Port qwen-realtime-session.ts and adapt it for the orchestration model: tools and instructions come from config, every declared tool dispatches through onFunctionCall with the raw argument payload, and outputs are receipt-style via submitFunctionOutput. The single-handoff machinery (multiple_handoffs error, steering acknowledgement, open-call complete) is removed; transport, response arbitration, pendingCalls timing, barge-in, and defensive validation are unchanged from the port. capturesTranscript marks handoff-style tools that capture the live transcript tail at call time. Test suite adapted: 27 tests green. * feat(qwen-live): add the BackendAdaptor seam and the qwen serve adaptor BackendAdaptor is the capability-bit-driven port between the live orchestrator and a coding-agent backend. QwenCodeAdaptor drives a running qwen serve daemon through the @qwen-code/sdk DaemonClient: 202-admission prompts, SSE event normalization (chunks and tool calls live under session_update.data.update), reject-if-idle mid-turn steering with a plain-prompt fallback, nested permission votes with voter attribution, and attachment-backed image blocks. 23 unit tests against an injected fake client. * feat(qwen-live): orchestrator, tools, injector, permissions, and daemon entry The M1+M2 core: - seven receipt-style dispatch tools plus remain_silent, with a dispatcher that answers every call (timeout fallback included) and session_N/job_N/asset_N handle registries - LiveSession wires Host audio, the realtime connection, tool handlers (appshot via the Host capture channel, handoff with voice-context packing and image attachments, busy-session steering), backend event pumps, and a bounded graceful-stop drain - Injector: safe-insertion window (VAD + response-in-flight + playback estimate at 48 kB/s with a quiet gap), batch aggregation, per-job progress throttling, barge-in drops progress, spoken/detail split - PermissionBroker: allow_always stays a one-shot protocol vote backed by a local TTL rule; remote resolutions retract queued asks - LiveDaemon: Host WS endpoint (Bearer + Origin-reject), discovery publish with fail-fast mutual exclusion and dead-owner handoff, appshot channel marked ready (the in-process capture path needs no separate verification), SIGINT/SIGTERM shutdown - SessionLog: JSONL with synchronous rotation (rename-before-reopen) 116 new unit tests; package total 167 green. * feat(qwen-live): end-to-end test suites against a real qwen serve daemon Test stack: a real qwen serve (spawned CLI bundle, fake-openai model endpoint), a scriptable fake DashScope realtime WebSocket server, the qwen-live daemon as a child process, and a protocol-v6 FakeHost client — every boundary is the real wire. Scenarios (11 tests): discovery publish/remove and hello/welcome, toggle brings up the realtime session (tool schema + instructions asserted on session.update), two-way audio byte fidelity, the full handoff loop against real serve with [COMPLETE] injection and spoken summary, injection-window hold/batch semantics, permission ask/vote relay, and busy-session steering tiers. The suites caught a real bug the unit layer could not: per-session calls must echo the daemon-issued clientId from the create/attach response (a self-made id is rejected by the client registration guard and broke handoff admission and vote attribution). QwenCodeAdaptor now tracks and echoes the issued id per session. * chore(release): keep @qwen-code/qwen-live versioned independently Same treatment as sdk/mobile-mcp/node-repl: the incubating package stays at its own 0.x version instead of lock-stepping with the CLI release train. The verbatim guard in package-scripts.test.js is updated alongside. * fix(qwen-live): harden review findings across dispatcher, injector, and permission relay Self-review fixes bundled after the adversarial pass: - adaptor: classify snake_case permission option ids (proceed_once) — the word-boundary regex never matched through "_", so an allow vote silently degraded to a cancelled outcome and serve dropped the turn - adaptor: detect the pending-prompt limit by error name as well as status, and fall image-bearing steers back to a regular prompt - dispatcher: a timed-out tool now answers with a pending receipt that tells the model not to retry (the handler keeps running) - injector: permission items flush ahead of the size-capped context join; spoken lines merge whole-line greedily under the 280-char cap - live-session: resubscribe the event pump with backoff until session_closed; a rejected permission chain can no longer become an unhandled rejection; receipts are clamped and never empty - permission broker: a failed auto-approval falls back to the spoken ask instead of losing the request - daemon/config/index: appshot readiness is declared at construction, numeric config ports parse, stray rejections are logged not fatal - e2e harness: QWEN_LIVE_E2E_KEEP=1 keeps the temp dirs for post-mortem * fix(qwen-live): address review round 1 — permission voting, shutdown, discovery reclaim, and hardening Permission trust boundary: - classify options by the wire's structured kind enum (allow_once/ allow_always/reject_*) instead of word regexes over snake_case ids, and vote the least-escalating match — a spoken "allow" now lands on proceed_once, never proceed_always_project - respond_permission's note is no longer discarded: it is logged and relayed to the backend session as a follow-up user instruction - track own votes per session so byUs attribution works on adopted sessions; narrow allow_always standing rules to the exact normalized command instead of a two-token prefix Lifecycle: - daemon stop() no longer hangs when SIGINT lands before listen() or when a Host redials during shutdown (stopping gate + client terminate) - dead-owner discovery reclaim now follows the cli contract (no-op commitOwner during handoff, republish after) — previously it could never succeed and a crash left the daemon unable to start - stop drain settles on the input-commit ack instead of burning the full 30s budget; response.created during the drain no longer flips the call back to speaking; a host-initiated start can no longer race deactivate() Robustness: - a late ASR final for an already-consumed input item is ignored as stale_input instead of dropping the call - raise the Host frame cap to cover a conformant CJK accessibility dump (64KiB frame cap contradicted the 32k-char field cap) - bin entry works through npm's bin symlink (realpath + pathToFileURL) - steered handoffs that join a running turn reuse the existing job instead of orphaning it; over-long steers fall back to a queued prompt; session_list unions created cwds, requests a full page, and maps hasActivePrompt for untracked sessions; created-session labels are applied via session metadata - injector: jobless throttle keys use the full context, dedup is symmetric, and barge-in drops clear the throttle stamp; sentence shapers handle CJK punctuation and mid-token periods - config loader: ENOENT-only swallow, BOM strip, ~ expansion, typed port validation, source-accurate errors; session log close() is terminal; transcript retention is capped Tests and docs: new config/daemon/sanitize suites, win32 guards restored on discovery mode assertions, protocol parity test now checks both type copies, qwen-live e2e suites join the no-AK PR gate, fake model gating in the inject e2e matches only the current turn and is consumed on match, FakeHost answers capture requests, lockfile diff reduced to qwen-live-only entries, README states the installer gap. * test(qwen-live): pin injector size caps and steered-message delivery - injector: 6000-char combined-context cap, whole-line spoken join at the 280-char boundary, single-line ellipsis truncation - steering e2e: assert the joined instruction actually reached the model endpoint, not just the receipt wording * fix(sdk): raise daemon browser bundle budget Co-authored-by: Qwen-Coder <[email protected]> * feat(qwen-live): scaffold standalone live daemon package with ported host stack New workspace package @qwen-code/qwen-live (M1+M2 of the Live split, issue #10118). This commit adds the package skeleton and the verbatim ports that need no behavioral changes: - host/: Live Host protocol v6 types, LiveHostCoordinator, and the discovery-file writer, ported from packages/cli/src/serve/live/ together with their test suites (51 tests green) - realtime/socket.ts, realtime/sanitize.ts: the two helper symbols the realtime client depends on, extracted from cli ui utils - registered in scripts/build.js buildOrder and root vitest projects * feat(qwen-live): generalize the realtime client tool surface Port qwen-realtime-session.ts and adapt it for the orchestration model: tools and instructions come from config, every declared tool dispatches through onFunctionCall with the raw argument payload, and outputs are receipt-style via submitFunctionOutput. The single-handoff machinery (multiple_handoffs error, steering acknowledgement, open-call complete) is removed; transport, response arbitration, pendingCalls timing, barge-in, and defensive validation are unchanged from the port. capturesTranscript marks handoff-style tools that capture the live transcript tail at call time. Test suite adapted: 27 tests green. * feat(qwen-live): add the BackendAdaptor seam and the qwen serve adaptor BackendAdaptor is the capability-bit-driven port between the live orchestrator and a coding-agent backend. QwenCodeAdaptor drives a running qwen serve daemon through the @qwen-code/sdk DaemonClient: 202-admission prompts, SSE event normalization (chunks and tool calls live under session_update.data.update), reject-if-idle mid-turn steering with a plain-prompt fallback, nested permission votes with voter attribution, and attachment-backed image blocks. 23 unit tests against an injected fake client. * feat(qwen-live): orchestrator, tools, injector, permissions, and daemon entry The M1+M2 core: - seven receipt-style dispatch tools plus remain_silent, with a dispatcher that answers every call (timeout fallback included) and session_N/job_N/asset_N handle registries - LiveSession wires Host audio, the realtime connection, tool handlers (appshot via the Host capture channel, handoff with voice-context packing and image attachments, busy-session steering), backend event pumps, and a bounded graceful-stop drain - Injector: safe-insertion window (VAD + response-in-flight + playback estimate at 48 kB/s with a quiet gap), batch aggregation, per-job progress throttling, barge-in drops progress, spoken/detail split - PermissionBroker: allow_always stays a one-shot protocol vote backed by a local TTL rule; remote resolutions retract queued asks - LiveDaemon: Host WS endpoint (Bearer + Origin-reject), discovery publish with fail-fast mutual exclusion and dead-owner handoff, appshot channel marked ready (the in-process capture path needs no separate verification), SIGINT/SIGTERM shutdown - SessionLog: JSONL with synchronous rotation (rename-before-reopen) 116 new unit tests; package total 167 green. * feat(qwen-live): end-to-end test suites against a real qwen serve daemon Test stack: a real qwen serve (spawned CLI bundle, fake-openai model endpoint), a scriptable fake DashScope realtime WebSocket server, the qwen-live daemon as a child process, and a protocol-v6 FakeHost client — every boundary is the real wire. Scenarios (11 tests): discovery publish/remove and hello/welcome, toggle brings up the realtime session (tool schema + instructions asserted on session.update), two-way audio byte fidelity, the full handoff loop against real serve with [COMPLETE] injection and spoken summary, injection-window hold/batch semantics, permission ask/vote relay, and busy-session steering tiers. The suites caught a real bug the unit layer could not: per-session calls must echo the daemon-issued clientId from the create/attach response (a self-made id is rejected by the client registration guard and broke handoff admission and vote attribution). QwenCodeAdaptor now tracks and echoes the issued id per session. * chore(release): keep @qwen-code/qwen-live versioned independently Same treatment as sdk/mobile-mcp/node-repl: the incubating package stays at its own 0.x version instead of lock-stepping with the CLI release train. The verbatim guard in package-scripts.test.js is updated alongside. * fix(qwen-live): harden review findings across dispatcher, injector, and permission relay Self-review fixes bundled after the adversarial pass: - adaptor: classify snake_case permission option ids (proceed_once) — the word-boundary regex never matched through "_", so an allow vote silently degraded to a cancelled outcome and serve dropped the turn - adaptor: detect the pending-prompt limit by error name as well as status, and fall image-bearing steers back to a regular prompt - dispatcher: a timed-out tool now answers with a pending receipt that tells the model not to retry (the handler keeps running) - injector: permission items flush ahead of the size-capped context join; spoken lines merge whole-line greedily under the 280-char cap - live-session: resubscribe the event pump with backoff until session_closed; a rejected permission chain can no longer become an unhandled rejection; receipts are clamped and never empty - permission broker: a failed auto-approval falls back to the spoken ask instead of losing the request - daemon/config/index: appshot readiness is declared at construction, numeric config ports parse, stray rejections are logged not fatal - e2e harness: QWEN_LIVE_E2E_KEEP=1 keeps the temp dirs for post-mortem * fix(qwen-live): address review round 1 — permission voting, shutdown, discovery reclaim, and hardening Permission trust boundary: - classify options by the wire's structured kind enum (allow_once/ allow_always/reject_*) instead of word regexes over snake_case ids, and vote the least-escalating match — a spoken "allow" now lands on proceed_once, never proceed_always_project - respond_permission's note is no longer discarded: it is logged and relayed to the backend session as a follow-up user instruction - track own votes per session so byUs attribution works on adopted sessions; narrow allow_always standing rules to the exact normalized command instead of a two-token prefix Lifecycle: - daemon stop() no longer hangs when SIGINT lands before listen() or when a Host redials during shutdown (stopping gate + client terminate) - dead-owner discovery reclaim now follows the cli contract (no-op commitOwner during handoff, republish after) — previously it could never succeed and a crash left the daemon unable to start - stop drain settles on the input-commit ack instead of burning the full 30s budget; response.created during the drain no longer flips the call back to speaking; a host-initiated start can no longer race deactivate() Robustness: - a late ASR final for an already-consumed input item is ignored as stale_input instead of dropping the call - raise the Host frame cap to cover a conformant CJK accessibility dump (64KiB frame cap contradicted the 32k-char field cap) - bin entry works through npm's bin symlink (realpath + pathToFileURL) - steered handoffs that join a running turn reuse the existing job instead of orphaning it; over-long steers fall back to a queued prompt; session_list unions created cwds, requests a full page, and maps hasActivePrompt for untracked sessions; created-session labels are applied via session metadata - injector: jobless throttle keys use the full context, dedup is symmetric, and barge-in drops clear the throttle stamp; sentence shapers handle CJK punctuation and mid-token periods - config loader: ENOENT-only swallow, BOM strip, ~ expansion, typed port validation, source-accurate errors; session log close() is terminal; transcript retention is capped Tests and docs: new config/daemon/sanitize suites, win32 guards restored on discovery mode assertions, protocol parity test now checks both type copies, qwen-live e2e suites join the no-AK PR gate, fake model gating in the inject e2e matches only the current turn and is consumed on match, FakeHost answers capture requests, lockfile diff reduced to qwen-live-only entries, README states the installer gap. * test(qwen-live): pin injector size caps and steered-message delivery - injector: 6000-char combined-context cap, whole-line spoken join at the 280-char boundary, single-line ellipsis truncation - steering e2e: assert the joined instruction actually reached the model endpoint, not just the receipt wording * feat(qwen-live): port the Live Host installer — M1 gap closed `live-host-installer.ts` moves verbatim from packages/cli/src/serve/live/ (same OSS/GitHub dual-source manifest fetch, sha256 + codesign + team-identifier verification, staging + backup install with rollback, and the retryable status machine), with its 11-test suite. The daemon gains the matching HTTP surface: - GET /live/setup — installed Host state - POST /live/setup/install — download + verify + install the latest POST /live/setup/launch — open the installed app All three sit behind the daemon's existing Bearer wall (a browser context sending Origin is always refused, wrong token 401) — pinned by a new daemon test; unknown paths stay 404 and GET /healthz answers plain 200. Bootstrap no longer depends on the built-in qwen-code Live integration having installed the Host first. * fix(qwen-live): attach the appshot-timeout rejection handler before advancing fake timers The timeout rejects synchronously inside advanceTimersByTimeAsync; a handler attached only afterwards leaves an unhandled rejection for an instant, which vitest counts as a run-level error (CI fails the package with Errors: 1 even though all 250 tests pass). Attach the expectation first, await it after the clock advances. * test(qwen-live): sync the no-AK gate pin with the qwen-live suites The no-ak-integration-ci pin (added upstream after our branch point) asserts the gate's file list verbatim; add the four qwen-live suites the gate has been running since they joined it. * fix(qwen-live): address review round 2 — trust-boundary keys, backpressure, session lifecycle Criticals (all probed and reproduced before fixing): - broker titleKeyOf: split on the FIRST colon and keep the entire remainder (commands routinely contain colons — a truncated key auto-approved a different -m message), and stop case-folding (/a vs /A are different paths on Linux). The whitespace-collapse pin is preserved - broker deny: an explicit spoken refusal now revokes the standing rule that matched the same request — the silent auto-answer's fallback path could otherwise be overridden by the invisible rule on the next identical request - adaptor activeJobRef: a busy-session fallback (image steer, over- long text 400) no longer overwrites the running turn's ref with the queued prompt's id — later steer receipts report the right job - adaptor byUs attribution: the daemon's originator stamp is now authoritative when present; ownVotes only covers anonymous resolutions. Another client resolving during our vote round-trip no longer reads as our own answer (R1-2 regression, fixed test-first) - adaptor permission titles: control sequences stripped before the title reaches speech or keys a standing rule (whole command kept — no first-line cut, unlike the progress path) - live-session pushAudio: the provider's backpressure signal is propagated (the port source fails the call) and onAudioDropped is wired to failCall — a socket over its buffer cap no longer silently garbles the utterance; the deliberate true for stale/connecting frames is untouched - handles: closed sessions stop resolving entirely (registry-level), and session_closed clears the call's default handle so a session- less handoff rebuilds instead of retrying a dead target forever - handles: read-site reconciliation — session_list and session_monitor transition a session's stale non-terminal jobs to done when the backend itself reports idle (a turn_complete emitted while no pump was subscribed no longer reports a running active_job forever) Suggestions on this PR's new surfaces: - live-host.yml: packages/qwen-live/src/host/** joins the path filter so the parity-test lane runs on qwen-live-only PRs instead of first failing in the merge queue - installer: the install route passes force=true (the recovery path for a corrupted-but-present app, matching the CLI mirror), and launch() gains the non-darwin guard in BOTH copies so the byte parity pin stays green - index: directory-form invocation (node packages/qwen-live) now runs main() instead of exiting 0 silently - realtime: the dead lastHandoffEntryCount machinery is removed (truncate-and-reset replaced the cursor it pretended to track) --------- Co-authored-by: Claude <[email protected]> Co-authored-by: 易良 <[email protected]> Co-authored-by: yiliang114 <[email protected]> Co-authored-by: Qwen-Coder <[email protected]>
545 lines
17 KiB
TypeScript
545 lines
17 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
IdeDiffAcceptedNotificationSchema,
|
|
IdeDiffClosedNotificationSchema,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js';
|
|
import * as path from 'node:path';
|
|
import * as vscode from 'vscode';
|
|
import { DIFF_SCHEME } from './extension.js';
|
|
import {
|
|
findLeftGroupOfChatWebview,
|
|
findRightGroupOfChatWebview,
|
|
} from './utils/editorGroupUtils.js';
|
|
|
|
export class DiffContentProvider implements vscode.TextDocumentContentProvider {
|
|
private content = new Map<string, string>();
|
|
private onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
|
|
|
|
get onDidChange(): vscode.Event<vscode.Uri> {
|
|
return this.onDidChangeEmitter.event;
|
|
}
|
|
|
|
provideTextDocumentContent(uri: vscode.Uri): string {
|
|
return this.content.get(uri.toString()) ?? '';
|
|
}
|
|
|
|
setContent(uri: vscode.Uri, content: string): void {
|
|
this.content.set(uri.toString(), content);
|
|
this.onDidChangeEmitter.fire(uri);
|
|
}
|
|
|
|
deleteContent(uri: vscode.Uri): void {
|
|
this.content.delete(uri.toString());
|
|
}
|
|
|
|
getContent(uri: vscode.Uri): string | undefined {
|
|
return this.content.get(uri.toString());
|
|
}
|
|
}
|
|
|
|
/** Options controlling how a diff editor is opened. */
|
|
export interface ShowDiffOptions {
|
|
/**
|
|
* Open the right-hand (proposed) side as read-only. Use this when the
|
|
* approval flow cannot round-trip user edits: the approving daemon tool
|
|
* applies its own proposed content, so an editable right side would
|
|
* silently discard anything the user typed (e.g. web-shell permission
|
|
* diffs opened while IDE mode is off).
|
|
*/
|
|
readOnly?: boolean;
|
|
/** WebShell permission request represented by this native diff. */
|
|
permissionRequestId?: string;
|
|
}
|
|
|
|
// Information about a diff view that is currently open.
|
|
interface DiffInfo {
|
|
originalFilePath: string;
|
|
oldContent: string;
|
|
newContent: string;
|
|
leftDocUri: vscode.Uri;
|
|
rightDocUri: vscode.Uri;
|
|
permissionRequestId?: string;
|
|
/**
|
|
* Whether the right-hand side was opened read-only. Reuse must match on
|
|
* this too: refocusing a writable twin for a read-only approval (or vice
|
|
* versa) would hand one flow the other flow's edit semantics.
|
|
*/
|
|
readOnly: boolean;
|
|
}
|
|
|
|
/**
|
|
* Manages the state and lifecycle of diff views within the IDE.
|
|
*/
|
|
export class DiffManager {
|
|
private readonly onDidChangeEmitter =
|
|
new vscode.EventEmitter<JSONRPCNotification>();
|
|
readonly onDidChange = this.onDidChangeEmitter.event;
|
|
private diffDocuments = new Map<string, DiffInfo>();
|
|
private readonly subscriptions: vscode.Disposable[] = [];
|
|
// Dedupe: remember recent showDiff calls keyed by (file+content)
|
|
private recentlyShown = new Map<string, number>();
|
|
private pendingDelayTimers = new Map<string, NodeJS.Timeout>();
|
|
private static readonly DEDUPE_WINDOW_MS = 1500;
|
|
// Optional hooks from extension to influence diff behavior
|
|
// - shouldDelay: when true, we defer opening diffs briefly (e.g., while a permission drawer is open)
|
|
// - shouldSuppress: when true, we skip opening diffs entirely (e.g., in auto/yolo mode)
|
|
private shouldDelay?: () => boolean;
|
|
private shouldSuppress?: () => boolean;
|
|
// Timed suppression window (e.g. immediately after permission allow)
|
|
private suppressUntil: number | null = null;
|
|
|
|
private getTargetViewColumn(
|
|
leftDocUri?: vscode.Uri,
|
|
rightDocUri?: vscode.Uri,
|
|
): vscode.ViewColumn {
|
|
if (leftDocUri && rightDocUri) {
|
|
const leftUri = leftDocUri.toString();
|
|
const rightUri = rightDocUri.toString();
|
|
for (const group of vscode.window.tabGroups.all) {
|
|
const containsDiff = group.tabs.some((tab) => {
|
|
const input = tab.input as
|
|
| {
|
|
original?: vscode.Uri;
|
|
modified?: vscode.Uri;
|
|
}
|
|
| undefined;
|
|
return (
|
|
input?.original?.toString() === leftUri &&
|
|
input?.modified?.toString() === rightUri
|
|
);
|
|
});
|
|
if (containsDiff) {
|
|
return group.viewColumn;
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
findLeftGroupOfChatWebview() ??
|
|
findRightGroupOfChatWebview() ??
|
|
vscode.window.activeTextEditor?.viewColumn ??
|
|
vscode.ViewColumn.Active
|
|
);
|
|
}
|
|
|
|
constructor(
|
|
private readonly log: (message: string) => void,
|
|
private readonly diffContentProvider: DiffContentProvider,
|
|
shouldDelay?: () => boolean,
|
|
shouldSuppress?: () => boolean,
|
|
) {
|
|
this.shouldDelay = shouldDelay;
|
|
this.shouldSuppress = shouldSuppress;
|
|
this.subscriptions.push(
|
|
vscode.window.onDidChangeActiveTextEditor((editor) => {
|
|
this.onActiveEditorChange(editor);
|
|
}),
|
|
);
|
|
this.onActiveEditorChange(vscode.window.activeTextEditor);
|
|
}
|
|
|
|
dispose() {
|
|
for (const subscription of this.subscriptions) {
|
|
subscription.dispose();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a diff view already exists for the given file path and content
|
|
* @param filePath Path to the file being diffed
|
|
* @param oldContent The original content (left side)
|
|
* @param newContent The modified content (right side)
|
|
* @param readOnly Writability the requester needs; only diffs with the
|
|
* same writability are reusable
|
|
* @returns True if a diff view with the same content already exists, false otherwise
|
|
*/
|
|
private hasExistingDiff(
|
|
filePath: string,
|
|
oldContent: string,
|
|
newContent: string,
|
|
readOnly: boolean,
|
|
permissionRequestId?: string,
|
|
): boolean {
|
|
for (const diffInfo of this.diffDocuments.values()) {
|
|
if (
|
|
diffInfo.originalFilePath === filePath &&
|
|
diffInfo.oldContent === oldContent &&
|
|
diffInfo.newContent === newContent &&
|
|
diffInfo.readOnly === readOnly &&
|
|
diffInfo.permissionRequestId === permissionRequestId
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Finds an existing diff view for the given file path and focuses it
|
|
* @param filePath Path to the file being diffed
|
|
* @param readOnly Only diffs opened with the same writability are eligible
|
|
* @returns True if an existing diff view was found and focused, false otherwise
|
|
*/
|
|
private async focusExistingDiff(
|
|
filePath: string,
|
|
readOnly: boolean,
|
|
permissionRequestId?: string,
|
|
): Promise<boolean> {
|
|
const normalizedPath = path.normalize(filePath);
|
|
for (const [, diffInfo] of this.diffDocuments.entries()) {
|
|
if (
|
|
diffInfo.originalFilePath === normalizedPath &&
|
|
diffInfo.readOnly === readOnly &&
|
|
diffInfo.permissionRequestId === permissionRequestId
|
|
) {
|
|
const rightDocUri = diffInfo.rightDocUri;
|
|
const leftDocUri = diffInfo.leftDocUri;
|
|
|
|
const diffTitle = `${path.basename(filePath)} (Before ↔ After)`;
|
|
|
|
try {
|
|
await vscode.commands.executeCommand(
|
|
'vscode.diff',
|
|
leftDocUri,
|
|
rightDocUri,
|
|
diffTitle,
|
|
{
|
|
viewColumn: this.getTargetViewColumn(leftDocUri, rightDocUri),
|
|
preview: false,
|
|
preserveFocus: true,
|
|
},
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
this.log(`Failed to focus existing diff: ${error}`);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Creates and shows a new diff view.
|
|
* - Overload 1: showDiff(filePath, newContent)
|
|
* - Overload 2: showDiff(filePath, oldContent, newContent)
|
|
* If only newContent is provided, the old content will be read from the
|
|
* filesystem (empty string when file does not exist).
|
|
*/
|
|
async showDiff(
|
|
filePath: string,
|
|
newContent: string,
|
|
options?: ShowDiffOptions,
|
|
): Promise<void>;
|
|
async showDiff(
|
|
filePath: string,
|
|
oldContent: string,
|
|
newContent: string,
|
|
options?: ShowDiffOptions,
|
|
): Promise<void>;
|
|
async showDiff(
|
|
filePath: string,
|
|
a: string,
|
|
b?: string | ShowDiffOptions,
|
|
options?: ShowDiffOptions,
|
|
): Promise<void> {
|
|
const haveOld = typeof b === 'string';
|
|
const resolvedOptions = haveOld ? options : b;
|
|
const readOnly = resolvedOptions?.readOnly === true;
|
|
const oldContent = haveOld ? a : await this.readOldContentFromFs(filePath);
|
|
const newContent = haveOld ? (b as string) : a;
|
|
const normalizedPath = path.normalize(filePath);
|
|
const key = this.makeKey(normalizedPath, oldContent, newContent);
|
|
|
|
// Check if a diff view with the same content, writability, and permission
|
|
// owner already exists. A read-only approval must never be deduped onto a
|
|
// writable diff, and two permission requests must not share a diff.
|
|
if (
|
|
this.hasExistingDiff(
|
|
normalizedPath,
|
|
oldContent,
|
|
newContent,
|
|
readOnly,
|
|
resolvedOptions?.permissionRequestId,
|
|
)
|
|
) {
|
|
const last = this.recentlyShown.get(key) || 0;
|
|
const now = Date.now();
|
|
if (now - last < DiffManager.DEDUPE_WINDOW_MS) {
|
|
// Within dedupe window: ignore the duplicate request entirely
|
|
this.log(
|
|
`Duplicate showDiff suppressed for ${filePath} (within ${DiffManager.DEDUPE_WINDOW_MS}ms)`,
|
|
);
|
|
return;
|
|
}
|
|
// Outside the dedupe window: softly focus the existing diff
|
|
await this.focusExistingDiff(
|
|
normalizedPath,
|
|
readOnly,
|
|
resolvedOptions?.permissionRequestId,
|
|
);
|
|
this.recentlyShown.set(key, now);
|
|
return;
|
|
}
|
|
// Left side: old content using qwen-diff scheme
|
|
// Use Uri.file() to properly handle Windows paths (e.g., C:\Users\...)
|
|
// then change the scheme to our custom diff scheme
|
|
const leftDocUri = vscode.Uri.file(normalizedPath).with({
|
|
scheme: DIFF_SCHEME,
|
|
query: `old&rand=${Math.random()}`,
|
|
});
|
|
this.diffContentProvider.setContent(leftDocUri, oldContent);
|
|
|
|
// Right side: new content using qwen-diff scheme
|
|
const rightDocUri = vscode.Uri.file(normalizedPath).with({
|
|
scheme: DIFF_SCHEME,
|
|
query: `new&rand=${Math.random()}`,
|
|
});
|
|
this.diffContentProvider.setContent(rightDocUri, newContent);
|
|
|
|
this.addDiffDocument(rightDocUri, {
|
|
originalFilePath: normalizedPath,
|
|
oldContent,
|
|
newContent,
|
|
leftDocUri,
|
|
rightDocUri,
|
|
readOnly,
|
|
permissionRequestId: resolvedOptions?.permissionRequestId,
|
|
});
|
|
|
|
const diffTitle = `${path.basename(normalizedPath)} (Before ↔ After)`;
|
|
await vscode.commands.executeCommand(
|
|
'setContext',
|
|
'qwen.diff.isVisible',
|
|
true,
|
|
);
|
|
|
|
// Prefer opening the diff in the group to the left of the chat webview.
|
|
// When that isn't available (e.g. chat is in the leftmost group), try the
|
|
// group to the right so we reuse existing layout. Sidebar chat has no
|
|
// editor group, so fall back to the active group rather than creating one.
|
|
const targetViewColumn = this.getTargetViewColumn();
|
|
|
|
await vscode.commands.executeCommand(
|
|
'vscode.diff',
|
|
leftDocUri,
|
|
rightDocUri,
|
|
diffTitle,
|
|
{
|
|
viewColumn: targetViewColumn,
|
|
preview: false,
|
|
preserveFocus: true,
|
|
},
|
|
);
|
|
// The writeable-in-session flag exists so users can adjust the proposed
|
|
// content before accepting; that only round-trips when an IDE-mode
|
|
// resolver consumes the edited text. Read-only callers (web-shell
|
|
// permission approvals) would silently lose edits, so keep them locked.
|
|
if (!readOnly) {
|
|
await vscode.commands.executeCommand(
|
|
'workbench.action.files.setActiveEditorWriteableInSession',
|
|
);
|
|
}
|
|
|
|
this.recentlyShown.set(key, Date.now());
|
|
}
|
|
|
|
/**
|
|
* Closes an open diff view for a specific file.
|
|
*/
|
|
async closeDiff(
|
|
filePath: string,
|
|
suppressNotification = false,
|
|
permissionRequestId?: string,
|
|
) {
|
|
const normalizedPath = path.normalize(filePath);
|
|
let uriToClose: vscode.Uri | undefined;
|
|
for (const [, diffInfo] of this.diffDocuments.entries()) {
|
|
if (
|
|
diffInfo.originalFilePath === normalizedPath &&
|
|
(permissionRequestId === undefined ||
|
|
diffInfo.permissionRequestId === permissionRequestId)
|
|
) {
|
|
uriToClose = diffInfo.rightDocUri;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (uriToClose) {
|
|
const rightDoc = await vscode.workspace.openTextDocument(uriToClose);
|
|
const modifiedContent = rightDoc.getText();
|
|
await this.closeDiffEditor(uriToClose);
|
|
if (!suppressNotification) {
|
|
this.onDidChangeEmitter.fire(
|
|
IdeDiffClosedNotificationSchema.parse({
|
|
jsonrpc: '2.0',
|
|
method: 'ide/diffClosed',
|
|
params: {
|
|
filePath,
|
|
content: modifiedContent,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
return modifiedContent;
|
|
}
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* User accepts the changes in a diff view. Does not apply changes.
|
|
*/
|
|
async acceptDiff(rightDocUri: vscode.Uri) {
|
|
const diffInfo = this.diffDocuments.get(rightDocUri.toString());
|
|
if (!diffInfo) {
|
|
this.log(`No diff info found for ${rightDocUri.toString()}`);
|
|
return;
|
|
}
|
|
|
|
const rightDoc = await vscode.workspace.openTextDocument(rightDocUri);
|
|
const modifiedContent = rightDoc.getText();
|
|
await this.closeDiffEditor(rightDocUri);
|
|
|
|
this.onDidChangeEmitter.fire(
|
|
IdeDiffAcceptedNotificationSchema.parse({
|
|
jsonrpc: '2.0',
|
|
method: 'ide/diffAccepted',
|
|
params: {
|
|
filePath: diffInfo.originalFilePath,
|
|
content: modifiedContent,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
getPermissionRequestId(rightDocUri: vscode.Uri): string | undefined {
|
|
return this.diffDocuments.get(rightDocUri.toString())?.permissionRequestId;
|
|
}
|
|
|
|
hasDiff(rightDocUri: vscode.Uri): boolean {
|
|
return this.diffDocuments.has(rightDocUri.toString());
|
|
}
|
|
|
|
/**
|
|
* Called when a user cancels a diff view.
|
|
*/
|
|
async cancelDiff(rightDocUri: vscode.Uri) {
|
|
const diffInfo = this.diffDocuments.get(rightDocUri.toString());
|
|
if (!diffInfo) {
|
|
this.log(`No diff info found for ${rightDocUri.toString()}`);
|
|
// Even if we don't have diff info, we should still close the editor.
|
|
await this.closeDiffEditor(rightDocUri);
|
|
return;
|
|
}
|
|
|
|
const rightDoc = await vscode.workspace.openTextDocument(rightDocUri);
|
|
const modifiedContent = rightDoc.getText();
|
|
await this.closeDiffEditor(rightDocUri);
|
|
|
|
this.onDidChangeEmitter.fire(
|
|
IdeDiffClosedNotificationSchema.parse({
|
|
jsonrpc: '2.0',
|
|
method: 'ide/diffClosed',
|
|
params: {
|
|
filePath: diffInfo.originalFilePath,
|
|
content: modifiedContent,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
private async onActiveEditorChange(editor: vscode.TextEditor | undefined) {
|
|
let isVisible = false;
|
|
if (editor) {
|
|
isVisible = this.diffDocuments.has(editor.document.uri.toString());
|
|
if (!isVisible) {
|
|
for (const document of this.diffDocuments.values()) {
|
|
if (document.originalFilePath === editor.document.uri.fsPath) {
|
|
isVisible = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await vscode.commands.executeCommand(
|
|
'setContext',
|
|
'qwen.diff.isVisible',
|
|
isVisible,
|
|
);
|
|
}
|
|
|
|
private addDiffDocument(uri: vscode.Uri, diffInfo: DiffInfo) {
|
|
this.diffDocuments.set(uri.toString(), diffInfo);
|
|
}
|
|
|
|
private async closeDiffEditor(rightDocUri: vscode.Uri) {
|
|
const diffInfo = this.diffDocuments.get(rightDocUri.toString());
|
|
await vscode.commands.executeCommand(
|
|
'setContext',
|
|
'qwen.diff.isVisible',
|
|
false,
|
|
);
|
|
|
|
if (diffInfo) {
|
|
this.diffDocuments.delete(rightDocUri.toString());
|
|
this.diffContentProvider.deleteContent(rightDocUri);
|
|
}
|
|
|
|
// Find and close the tab corresponding to the diff view
|
|
for (const tabGroup of vscode.window.tabGroups.all) {
|
|
for (const tab of tabGroup.tabs) {
|
|
const input = tab.input as {
|
|
modified?: vscode.Uri;
|
|
original?: vscode.Uri;
|
|
};
|
|
if (input && input.modified?.toString() === rightDocUri.toString()) {
|
|
await vscode.window.tabGroups.close(tab);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Close all open qwen-diff editors */
|
|
async closeAll(): Promise<void> {
|
|
// Collect keys first to avoid iterator invalidation while closing
|
|
const uris = Array.from(this.diffDocuments.keys()).map((k) =>
|
|
vscode.Uri.parse(k),
|
|
);
|
|
for (const uri of uris) {
|
|
try {
|
|
await this.closeDiffEditor(uri);
|
|
} catch (err) {
|
|
this.log(`Failed to close diff editor: ${err}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read the current content of file from the workspace; return empty string if not found
|
|
private async readOldContentFromFs(filePath: string): Promise<string> {
|
|
try {
|
|
const fileUri = vscode.Uri.file(filePath);
|
|
const document = await vscode.workspace.openTextDocument(fileUri);
|
|
return document.getText();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
private makeKey(filePath: string, oldContent: string, newContent: string) {
|
|
// Simple stable key; content could be large but kept transiently
|
|
return `${filePath}\u241F${oldContent}\u241F${newContent}`;
|
|
}
|
|
|
|
/** Temporarily suppress opening diffs for a short duration. */
|
|
suppressFor(durationMs: number): void {
|
|
this.suppressUntil = Date.now() + Math.max(0, durationMs);
|
|
}
|
|
}
|