feat(chat): Comments and review in VS Code, like the OpenChamber desktop app (#1724)
* feat(chat): render code comments as cards instead of fenced text * fix(vscode): route Add Comment to the active session editor panel * fix(chat): persist queued inline comments and tighten file-chip path matching * feat(vscode): comment on code from the editor * fix(chat): keep attached context in the message and broadcast comment removal * fix(vscode): hold every pending comment and gate both entry points on the workspace * fix(vscode): let only the owning surface decide its comment threads * fix(vscode): drop a comment removed while its delivery was still in flight * test(vscode): cover the in-flight comment removal guard * test(vscode): cover comment removal reaching every chat surface * fix(vscode): give up on a comment the chat never confirmed holding * fix(vscode): retract a comment everywhere before reporting it discarded * fix(chat): preserve queued comment cards * fix: preserve inline comment context across send paths * fix(chat): preserve command routing with context * fix(chat): keep unavailable actions on normal send path --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d323b51a0a
commit
a12b9be443
@@ -9,6 +9,7 @@ import { openSseProxy } from './sseProxy';
|
||||
import { resolveWebviewDevServerUrl } from './webviewDevServer';
|
||||
import { normalizeWindowsDriveLetter } from './pathUtils';
|
||||
import { resolveWorkspaceFolders, type WorkspaceFolderCandidate } from './workspaceResolver';
|
||||
import { SIDEBAR_SURFACE_ID } from './InlineCommentThreads';
|
||||
|
||||
type ActiveEditorFilePayload = {
|
||||
filePath: string;
|
||||
@@ -150,6 +151,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
return;
|
||||
}
|
||||
|
||||
// Editor comment threads mirror the composer's drafts, so the webview
|
||||
// reports every change. Handled before the id check because this is a
|
||||
// one-way notification, not a bridge request awaiting a response.
|
||||
if (message.type === 'inlineComments:sync') {
|
||||
// Tagged with the sidebar's identity: a snapshot only speaks for the
|
||||
// store that produced it, and each session panel has its own.
|
||||
void vscode.commands.executeCommand('openchamber.internal.inlineCommentsSync', {
|
||||
snapshot: message.payload,
|
||||
surfaceId: SIDEBAR_SURFACE_ID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('id' in message) || typeof message.id !== 'string') {
|
||||
return;
|
||||
}
|
||||
@@ -249,6 +263,28 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
});
|
||||
}
|
||||
|
||||
public addLineComment(payload: { draftId?: string; filePath: string; relativePath: string; source: 'diff' | 'file'; side?: 'original' | 'modified'; startLine: number; endLine: number; code: string; language: string; comment: string }) {
|
||||
if (!this._view) return;
|
||||
// Bring the chat into view like the other capture flows do, so the chip the
|
||||
// comment becomes is visible rather than waiting behind a collapsed panel.
|
||||
this._view.show(true);
|
||||
this._view.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'addLineComment',
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
/** Drops a draft the user removed from its editor thread. */
|
||||
public removeLineComment(draftId: string) {
|
||||
if (!this._view) return;
|
||||
this._view.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'removeLineComment',
|
||||
payload: { draftId },
|
||||
});
|
||||
}
|
||||
|
||||
public addFileAttachments(files: Array<{ filePath: string; fileName: string; fileSize: number | null }>) {
|
||||
if (!this._view) {
|
||||
return;
|
||||
|
||||
@@ -82,6 +82,13 @@ The webview build emits each worker as one self-contained file. VS Code webviews
|
||||
- Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract.
|
||||
- Serializes reads and read-modify-write updates, persists a monotonic policy revision, and broadcasts the exact committed snapshot to every active OpenChamber webview. Permission replies remain foreground UI-owned because VS Code does not run the OpenChamber server runtime.
|
||||
|
||||
- `InlineCommentThreads.ts`
|
||||
- Owns the `openchamber.inlineComments` comment controller: the gutter `+` range, the thread opened by `openchamber.addLineComment`, and every thread a submitted comment leaves anchored in the editor until the message goes out.
|
||||
- A thread never owns a draft. It mints the draft id, hands the payload to a chat webview (the active or newly opened session panel, else the sidebar), and follows the webview's whole-draft-list `inlineComments:sync` snapshots: present means show, absent after having been seen means dispose. A snapshot is tagged with the surface that produced it (a panel id or `sidebar`) and only decides that surface's own threads, because every webview runs its own draft store.
|
||||
- A comment the composer never confirms holding within 30 s is retracted from every surface's pending hold, its thread disposed, and the user told, so a thread cannot promise a send that will never happen.
|
||||
- `inlineCommentSelection.ts` holds the pure pieces (line ranges, the diff-side and real-path resolution for `git:` documents, the pending hold, removal broadcast, thread fate) without the `vscode` import so they are unit-tested directly.
|
||||
- Webview side: `webview/inlineCommentRemovals.ts` remembers removals that arrive before a delayed delivery lands, so a comment dropped while its panel was still booting does not appear as a chip later. The extension is not activated on startup for this; the right-click command activates it, and the gutter `+` appears from then on.
|
||||
|
||||
## Shared webview message ordering
|
||||
|
||||
Message and part ordering is owned by [`packages/ui/src/sync/DOCUMENTATION.md`](../../ui/src/sync/DOCUMENTATION.md#session-message-loading). The VS Code webview consumes that shared sync implementation; bridge and proxy runtimes pass OpenCode records through without adding runtime-specific ordering.
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Code comments written in the editor itself.
|
||||
*
|
||||
* A comment is written while looking at the code it is about, so it is captured
|
||||
* where the code is: right-click a selection (or use the gutter `+`) and a
|
||||
* thread opens on those lines. The thread stays anchored there, showing what
|
||||
* will be sent, until the message goes out or the comment is dropped.
|
||||
*
|
||||
* The composer's chips remain the list of what is attached. This module is the
|
||||
* editor-side view of that same list, which is why it never owns a draft: it
|
||||
* mints the id, hands the draft to the webview, and disposes its thread when
|
||||
* the webview reports the draft gone. The webview store stays authoritative,
|
||||
* so a comment removed from the chip row cannot linger in the editor.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
import { DELIVERY_CONFIRMATION_TIMEOUT_MS, canCommentOnDocument, nextDraftId, reconcileThreadFate, resolveCommentFilePath, resolveCommentOrigin, selectionLineRange, shouldAbandonUnconfirmed, shouldDisposeOnEmptyBody, snapshotOwnsThread, type CommentOrigin, type LineRange } from './inlineCommentSelection';
|
||||
|
||||
// Also written literally in package.json, which gates the thread menus with
|
||||
// `commentController == openchamber.inlineComments`. JSON cannot import, so the
|
||||
// two have to be kept in step by hand.
|
||||
const INLINE_COMMENT_CONTROLLER_ID = 'openchamber.inlineComments';
|
||||
|
||||
export interface InlineCommentDraftPayload {
|
||||
draftId: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
source: 'diff' | 'file';
|
||||
side?: 'original' | 'modified';
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
code: string;
|
||||
language: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
interface OpenChamberCommentThread extends vscode.CommentThread {
|
||||
draftId?: string;
|
||||
/** Diff identity captured while the thread's editor is authoritative. */
|
||||
commentOrigin?: CommentOrigin;
|
||||
/** Last body written to this thread, so reconciliation can skip no-op renders. */
|
||||
commentBody?: string;
|
||||
/**
|
||||
* Whether the composer has ever reported holding this draft.
|
||||
*
|
||||
* Delivery is asynchronous, so a snapshot can arrive describing the moment
|
||||
* before the draft landed. Absence only means "removed" once presence has
|
||||
* been seen at least once.
|
||||
*/
|
||||
confirmed?: boolean;
|
||||
/**
|
||||
* The chat webview holding this comment's draft.
|
||||
*
|
||||
* Only that surface's snapshots can decide this thread's fate; every other
|
||||
* webview has its own store where the draft never existed.
|
||||
*/
|
||||
surfaceId?: string;
|
||||
/** Deadline for the composer to confirm it holds this draft. */
|
||||
confirmationTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/** Identifies one chat webview: a session panel id, or the sidebar. */
|
||||
export const SIDEBAR_SURFACE_ID = 'sidebar';
|
||||
|
||||
export interface InlineCommentThreadsOptions {
|
||||
/**
|
||||
* Hands a finished draft to a chat webview.
|
||||
*
|
||||
* Returns the id of the surface that accepted it, or null when none did.
|
||||
* The identity matters: only that surface's later snapshots can speak for
|
||||
* this comment, because every webview holds its own draft store.
|
||||
*/
|
||||
submitDraft: (payload: InlineCommentDraftPayload) => Promise<string | null> | string | null;
|
||||
/** Asks the webview to drop a draft the user removed from the editor side. */
|
||||
removeDraft: (draftId: string) => void;
|
||||
/** Tells the user a comment never reached the composer and was given up on. */
|
||||
reportUndelivered: () => void;
|
||||
/** The extension's own icon, shown as the comment's avatar. */
|
||||
avatar: vscode.Uri;
|
||||
/** Localized strings, injected so this module does not reach for the l10n bundle. */
|
||||
strings: {
|
||||
threadLabel: (range: LineRange) => string;
|
||||
author: string;
|
||||
notSent: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the comment controller and every thread currently on screen.
|
||||
*
|
||||
* Threads are keyed by draft id once submitted. Before submission a thread has
|
||||
* no draft yet, so it is tracked only by the controller and disposed on cancel.
|
||||
*/
|
||||
export class InlineCommentThreads implements vscode.Disposable {
|
||||
private readonly controller: vscode.CommentController;
|
||||
private readonly threadsByDraftId = new Map<string, OpenChamberCommentThread>();
|
||||
private readonly options: InlineCommentThreadsOptions;
|
||||
|
||||
constructor(options: InlineCommentThreadsOptions) {
|
||||
this.options = options;
|
||||
this.controller = vscode.comments.createCommentController(
|
||||
INLINE_COMMENT_CONTROLLER_ID,
|
||||
'OpenChamber',
|
||||
);
|
||||
// Any line of a workspace file can take a comment; the gutter `+`
|
||||
// follows from this.
|
||||
this.controller.commentingRangeProvider = {
|
||||
provideCommentingRanges: (document) => {
|
||||
if (!this.canCommentOn(document.uri)) return [];
|
||||
return [new vscode.Range(0, 0, Math.max(document.lineCount - 1, 0), 0)];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this document can take a comment.
|
||||
*
|
||||
* Both entry points ask, so the gutter `+` and the right-click command
|
||||
* agree: a comment is filed against a workspace-relative path, and one
|
||||
* written outside the workspace would name a file that does not resolve.
|
||||
*/
|
||||
public canCommentOn(uri: vscode.Uri): boolean {
|
||||
const filePath = resolveCommentFilePath(uri.fsPath, uri.query);
|
||||
const inWorkspace = Boolean(vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath)));
|
||||
return canCommentOnDocument(uri.scheme, inWorkspace);
|
||||
}
|
||||
|
||||
/** Opens an empty thread on a selection, with the reply box focused. */
|
||||
public openThread(uri: vscode.Uri, range: vscode.Range): vscode.CommentThread {
|
||||
const lines = selectionLineRange(range);
|
||||
// SAFETY: this controller creates and owns the thread; the added fields
|
||||
// are optional extension-local bookkeeping on VS Code's mutable object.
|
||||
const thread = this.controller.createCommentThread(uri, range, []) as OpenChamberCommentThread;
|
||||
thread.commentOrigin = this.resolveOrigin(uri);
|
||||
thread.label = this.options.strings.threadLabel(lines);
|
||||
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
|
||||
thread.canReply = true;
|
||||
thread.contextValue = 'openchamberPending';
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a typed reply into a draft the composer will send.
|
||||
*
|
||||
* An empty body is a cancel: the thread is disposed rather than left behind
|
||||
* as a comment that will never be sent.
|
||||
*/
|
||||
public async submitReply(reply: { thread: vscode.CommentThread; text: string }): Promise<void> {
|
||||
// SAFETY: this command is registered only for threads created by this
|
||||
// controller, which are initialized as OpenChamberCommentThread above.
|
||||
const thread = reply.thread as OpenChamberCommentThread;
|
||||
if (shouldDisposeOnEmptyBody(reply.text)) {
|
||||
this.disposeThread(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
// A thread whose range the editor dropped (the file was closed or edited
|
||||
// out from under it) has nothing to anchor a comment to.
|
||||
const range = thread.range;
|
||||
if (!range) {
|
||||
this.disposeThread(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture before the first await. Opening a git document can yield long
|
||||
// enough for tab focus to move, while the thread still belongs to the
|
||||
// diff pane where the user submitted it.
|
||||
const origin = thread.commentOrigin ?? this.resolveOrigin(thread.uri);
|
||||
thread.commentOrigin = origin;
|
||||
const document = await vscode.workspace.openTextDocument(thread.uri);
|
||||
const lines = selectionLineRange(range);
|
||||
const draftId = nextDraftId(Date.now(), Math.random());
|
||||
|
||||
// The gutter `+` produces a thread VS Code created, which never went
|
||||
// through openThread and so carries no label. Set it here so both entry
|
||||
// points read the same.
|
||||
thread.label = this.options.strings.threadLabel(lines);
|
||||
|
||||
// Quote the pane the user commented on, but name the real file: a diff's
|
||||
// original side is a `git:` document, and its raw path is not something
|
||||
// the composer can match against the workspace.
|
||||
const filePath = resolveCommentFilePath(thread.uri.fsPath, thread.uri.query);
|
||||
const fileUri = vscode.Uri.file(filePath);
|
||||
|
||||
const payload: InlineCommentDraftPayload = {
|
||||
draftId,
|
||||
filePath,
|
||||
relativePath: vscode.workspace.asRelativePath(fileUri, false),
|
||||
...origin,
|
||||
startLine: lines.startLine,
|
||||
endLine: lines.endLine,
|
||||
code: document.getText(range),
|
||||
language: document.languageId,
|
||||
comment: reply.text,
|
||||
};
|
||||
|
||||
const surfaceId = await this.options.submitDraft(payload);
|
||||
if (!surfaceId) {
|
||||
// Nothing took the draft (no chat surface open). Leaving the thread
|
||||
// would promise an attachment that does not exist.
|
||||
this.disposeThread(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
thread.surfaceId = surfaceId;
|
||||
thread.draftId = draftId;
|
||||
thread.commentBody = reply.text;
|
||||
thread.canReply = false;
|
||||
thread.contextValue = 'openchamberAttached';
|
||||
thread.comments = [this.buildComment(reply.text)];
|
||||
this.threadsByDraftId.set(draftId, thread);
|
||||
|
||||
// Accepting the draft is not the same as it landing. A panel whose
|
||||
// webview never boots leaves this thread showing "Not sent yet" for a
|
||||
// comment that will never be sent and cannot be rewritten, so it is
|
||||
// given up on rather than left as a standing promise.
|
||||
thread.confirmationTimer = setTimeout(() => {
|
||||
thread.confirmationTimer = undefined;
|
||||
if (!shouldAbandonUnconfirmed(thread.confirmed)) return;
|
||||
// Retract it everywhere before saying it was discarded. Dropping only
|
||||
// the thread leaves the payload in a panel's hold, so a webview that
|
||||
// boots after the deadline would still file the draft and send a
|
||||
// comment the user was just told had been thrown away.
|
||||
this.options.removeDraft(draftId);
|
||||
this.disposeThread(thread);
|
||||
this.options.reportUndelivered();
|
||||
}, DELIVERY_CONFIRMATION_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private resolveOrigin(uri: vscode.Uri): CommentOrigin {
|
||||
const activeTabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input;
|
||||
if (activeTabInput instanceof vscode.TabInputTextDiff) {
|
||||
return resolveCommentOrigin(uri.toString(), uri.scheme, {
|
||||
original: activeTabInput.original.toString(),
|
||||
modified: activeTabInput.modified.toString(),
|
||||
});
|
||||
}
|
||||
return resolveCommentOrigin(uri.toString(), uri.scheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the editor threads in line with what the composer actually holds.
|
||||
*
|
||||
* The webview sends its whole current draft list rather than individual
|
||||
* events, so a dropped or reordered notification cannot leave a thread
|
||||
* anchored to a comment that will never be sent. Sending the message empties
|
||||
* the list, which clears every thread through the same path.
|
||||
*
|
||||
* Only threads this controller created are ever touched, so an unknown id in
|
||||
* the snapshot (a comment written in the in-app file viewer) is ignored
|
||||
* rather than treated as something to reconcile.
|
||||
*
|
||||
* A snapshot speaks only for the surface that sent it. Every webview keeps
|
||||
* its own draft store, so a second session tab reporting an empty list says
|
||||
* nothing about a comment attached to the first one.
|
||||
*/
|
||||
public reconcile(surfaceId: string, drafts: ReadonlyArray<{ id: string; text: string }>): void {
|
||||
const byId = new Map(drafts.map((draft) => [draft.id, draft.text]));
|
||||
|
||||
for (const [draftId, thread] of [...this.threadsByDraftId]) {
|
||||
if (!snapshotOwnsThread(thread.surfaceId, surfaceId)) continue;
|
||||
const text = byId.get(draftId);
|
||||
const fate = reconcileThreadFate(text, Boolean(thread.confirmed));
|
||||
|
||||
if (fate === 'wait') continue;
|
||||
if (fate === 'dispose') {
|
||||
this.disposeThread(thread);
|
||||
continue;
|
||||
}
|
||||
|
||||
thread.confirmed = true;
|
||||
if (thread.confirmationTimer) {
|
||||
clearTimeout(thread.confirmationTimer);
|
||||
thread.confirmationTimer = undefined;
|
||||
}
|
||||
if (text !== undefined && thread.commentBody !== text) {
|
||||
thread.commentBody = text;
|
||||
thread.comments = [this.buildComment(text)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a thread from the editor side.
|
||||
*
|
||||
* A thread that already carries a draft has to tell the composer, or the
|
||||
* chip would stay attached with nothing shown in the code.
|
||||
*/
|
||||
public removeThread(thread: vscode.CommentThread): void {
|
||||
// SAFETY: removeThread is wired only to this controller's comment menu.
|
||||
const draftId = (thread as OpenChamberCommentThread).draftId;
|
||||
if (draftId) {
|
||||
this.options.removeDraft(draftId);
|
||||
}
|
||||
this.disposeThread(thread);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
for (const thread of this.threadsByDraftId.values()) {
|
||||
if (thread.confirmationTimer) clearTimeout(thread.confirmationTimer);
|
||||
}
|
||||
this.threadsByDraftId.clear();
|
||||
this.controller.dispose();
|
||||
}
|
||||
|
||||
private buildComment(body: string): vscode.Comment {
|
||||
// The body is the user's own prose, not a document: rendering it as
|
||||
// Markdown would eat underscores and asterisks they meant literally,
|
||||
// and a comment on code is full of both.
|
||||
const rendered = new vscode.MarkdownString();
|
||||
rendered.appendText(body);
|
||||
|
||||
return {
|
||||
body: rendered,
|
||||
mode: vscode.CommentMode.Preview,
|
||||
author: { name: this.options.strings.author, iconPath: this.options.avatar },
|
||||
label: this.options.strings.notSent,
|
||||
contextValue: 'openchamberAttached',
|
||||
};
|
||||
}
|
||||
|
||||
private disposeThread(thread: OpenChamberCommentThread): void {
|
||||
if (thread.confirmationTimer) {
|
||||
clearTimeout(thread.confirmationTimer);
|
||||
thread.confirmationTimer = undefined;
|
||||
}
|
||||
if (thread.draftId) {
|
||||
this.threadsByDraftId.delete(thread.draftId);
|
||||
}
|
||||
thread.dispose();
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,40 @@ import { openSseProxy } from './sseProxy';
|
||||
import { resolveWebviewDevServerUrl } from './webviewDevServer';
|
||||
import { normalizeWindowsDriveLetter } from './pathUtils';
|
||||
import { resolveWorkspaceFolders } from './workspaceResolver';
|
||||
import { pickActivePanelId } from './activePanelRouting';
|
||||
import { broadcastRemoval, drainPending } from './inlineCommentSelection';
|
||||
|
||||
const t = vscode.l10n.t;
|
||||
|
||||
type LineCommentPayload = {
|
||||
draftId?: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
source: 'diff' | 'file';
|
||||
side?: 'original' | 'modified';
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
code: string;
|
||||
language: string;
|
||||
comment: string;
|
||||
};
|
||||
|
||||
type SessionPanelState = {
|
||||
/** This panel's id, which is also its surface identity for comment threads. */
|
||||
id: string;
|
||||
panel: vscode.WebviewPanel;
|
||||
sseStreams: Map<string, AbortController>;
|
||||
/**
|
||||
* Comments held until the webview proves it is listening. Posting into a
|
||||
* panel whose script has not booted drops the message outright, and the user
|
||||
* already saw the comment accepted.
|
||||
*
|
||||
* A list, because a second comment can be written while the panel is still
|
||||
* starting; a single slot silently discarded the first.
|
||||
*/
|
||||
pendingLineComments: LineCommentPayload[];
|
||||
/** Set by the panel's first inbound message, the only proof its script runs. */
|
||||
webviewReady?: boolean;
|
||||
};
|
||||
|
||||
type ActiveEditorFilePayload = {
|
||||
@@ -119,8 +147,10 @@ export class SessionEditorPanelProvider {
|
||||
};
|
||||
|
||||
const state: SessionPanelState = {
|
||||
id: panelId,
|
||||
panel,
|
||||
sseStreams: new Map(),
|
||||
pendingLineComments: [],
|
||||
};
|
||||
|
||||
this._panels.set(panelId, state);
|
||||
@@ -146,6 +176,29 @@ export class SessionEditorPanelProvider {
|
||||
}, null, this._context.subscriptions);
|
||||
|
||||
panel.webview.onDidReceiveMessage(async (message: BridgeRequest) => {
|
||||
// Any inbound message proves the webview script is running, which is the
|
||||
// only readiness signal this panel has. Flush whatever was held for it.
|
||||
state.webviewReady = true;
|
||||
for (const pending of drainPending(state.pendingLineComments)) {
|
||||
void panel.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'addLineComment',
|
||||
payload: pending,
|
||||
});
|
||||
}
|
||||
|
||||
// Editor comment threads mirror the composer's drafts, so the webview
|
||||
// reports every change. One-way notification, no response expected.
|
||||
if (message.type === 'inlineComments:sync') {
|
||||
// Tagged with this panel's identity: a snapshot only speaks for the
|
||||
// store that produced it, and every panel has its own.
|
||||
void vscode.commands.executeCommand('openchamber.internal.inlineCommentsSync', {
|
||||
snapshot: message.payload,
|
||||
surfaceId: panelId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'restartApi') {
|
||||
await this._openCodeManager?.restart();
|
||||
return;
|
||||
@@ -246,8 +299,10 @@ export class SessionEditorPanelProvider {
|
||||
}
|
||||
|
||||
private _getActivePanelEntry(): SessionPanelState | null {
|
||||
const activeEntry = Array.from(this._panels.entries()).find(([, entry]) => entry.panel.active);
|
||||
const panelId = activeEntry?.[0] ?? this._lastActivePanelId;
|
||||
const panelId = pickActivePanelId(
|
||||
Array.from(this._panels.entries()).map(([id, entry]) => ({ id, active: entry.panel.active })),
|
||||
this._lastActivePanelId,
|
||||
);
|
||||
if (!panelId) {
|
||||
return null;
|
||||
}
|
||||
@@ -274,6 +329,105 @@ export class SessionEditorPanelProvider {
|
||||
return true;
|
||||
}
|
||||
|
||||
public addLineCommentToActivePanel(payload: {
|
||||
draftId?: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
source: 'diff' | 'file';
|
||||
side?: 'original' | 'modified';
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
code: string;
|
||||
language: string;
|
||||
comment: string;
|
||||
}): string | null {
|
||||
if (!payload.relativePath.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = this._getActivePanelEntry();
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
entry.panel.reveal(entry.panel.viewColumn ?? vscode.ViewColumn.Active, true);
|
||||
|
||||
// An existing panel can still be booting (reopened from a restored window),
|
||||
// and a post into a webview whose script has not run is dropped outright.
|
||||
// Hold it on the same path a freshly opened panel uses.
|
||||
if (!entry.webviewReady) {
|
||||
entry.pendingLineComments.push(payload);
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
void entry.panel.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'addLineComment',
|
||||
payload,
|
||||
});
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers a comment to a session tab, opening one when none exists.
|
||||
*
|
||||
* A comment is written against code the user is reading, so it must not
|
||||
* depend on their having opened a chat first. With no tab open this behaves
|
||||
* like the toolbar's new-session button, then delivers into that tab once its
|
||||
* webview is listening.
|
||||
*/
|
||||
public openWithLineComment(payload: LineCommentPayload, activeSessionId: string | null): string | null {
|
||||
if (!payload.relativePath.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accepted = this.addLineCommentToActivePanel(payload);
|
||||
if (accepted) {
|
||||
return accepted;
|
||||
}
|
||||
|
||||
if (activeSessionId) {
|
||||
this.createOrShow(activeSessionId);
|
||||
} else {
|
||||
this.createOrShowNewSession();
|
||||
}
|
||||
|
||||
const entry = this._getActivePanelEntry();
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
entry.pendingLineComments.push(payload);
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a draft the user removed from its editor thread.
|
||||
*
|
||||
* Sent to every panel, not just the active one: each webview owns its own
|
||||
* draft store, and the draft may have landed in a tab the user has since
|
||||
* moved away from. Targeting only the active panel made removal a silent
|
||||
* no-op in that case, leaving the chip attached after its thread was gone.
|
||||
*
|
||||
* Unlike adding, this does not reveal a panel: the user is looking at the
|
||||
* code, and stealing focus to show a chip disappearing would be worse than
|
||||
* letting it disappear quietly.
|
||||
*/
|
||||
public removeLineComment(draftId: string): void {
|
||||
const targets = [...this._panels.values()].map((state) => ({
|
||||
pendingLineComments: state.pendingLineComments,
|
||||
notify: () => {
|
||||
void state.panel.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'removeLineComment',
|
||||
payload: { draftId },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
broadcastRemoval(targets, draftId);
|
||||
}
|
||||
|
||||
public createSessionWithPromptInActivePanel(prompt: string): boolean {
|
||||
if (!prompt.trim()) {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { pickActivePanelId } from './activePanelRouting';
|
||||
|
||||
describe('pickActivePanelId', () => {
|
||||
test('returns null when there are no panels and no recent panel', () => {
|
||||
assert.equal(pickActivePanelId([], null), null);
|
||||
});
|
||||
|
||||
test('falls back to the last active panel when none is currently focused', () => {
|
||||
// A chat panel exists but the user is focused elsewhere (e.g. the code editor).
|
||||
const panels = [{ id: 'ses_a', active: false }];
|
||||
assert.equal(pickActivePanelId(panels, 'ses_a'), 'ses_a');
|
||||
});
|
||||
|
||||
test('prefers the currently focused panel over the last active one', () => {
|
||||
const panels = [
|
||||
{ id: 'ses_a', active: false },
|
||||
{ id: 'ses_b', active: true },
|
||||
];
|
||||
assert.equal(pickActivePanelId(panels, 'ses_a'), 'ses_b');
|
||||
});
|
||||
|
||||
test('uses the focused panel even when there is no recorded last active panel', () => {
|
||||
assert.equal(pickActivePanelId([{ id: 'ses_b', active: true }], null), 'ses_b');
|
||||
});
|
||||
|
||||
test('returns the last active panel when no panel is focused', () => {
|
||||
const panels = [
|
||||
{ id: 'ses_a', active: false },
|
||||
{ id: 'ses_b', active: false },
|
||||
];
|
||||
assert.equal(pickActivePanelId(panels, 'ses_b'), 'ses_b');
|
||||
});
|
||||
|
||||
test('returns null when nothing is focused and there is no recent panel', () => {
|
||||
assert.equal(pickActivePanelId([{ id: 'ses_a', active: false }], null), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Pure selection logic shared by the session editor panel routing
|
||||
* (`*ToActivePanel` methods). Kept free of the `vscode` dependency so it can be
|
||||
* unit tested in isolation.
|
||||
*
|
||||
* A right-click command targets the panel the user is currently in. We prefer a
|
||||
* panel that is actively focused; otherwise we fall back to the panel that was
|
||||
* focused most recently. The caller is responsible for confirming the returned
|
||||
* id still maps to a live panel.
|
||||
*/
|
||||
export function pickActivePanelId(
|
||||
panels: Array<{ id: string; active: boolean }>,
|
||||
lastActivePanelId: string | null,
|
||||
): string | null {
|
||||
const active = panels.find((panel) => panel.active);
|
||||
return active?.id ?? lastActivePanelId ?? null;
|
||||
}
|
||||
@@ -6,8 +6,21 @@ import { createOpenCodeManager, type OpenCodeManager } from './opencode';
|
||||
import { startGlobalEventWatcher, stopGlobalEventWatcher, setChatViewProvider } from './sessionActivityWatcher';
|
||||
import { pathsEqualWithNormalizedDriveLetter } from './pathUtils';
|
||||
import { resolveWorkspaceFolders } from './workspaceResolver';
|
||||
import { InlineCommentThreads, SIDEBAR_SURFACE_ID } from './InlineCommentThreads';
|
||||
|
||||
let chatViewProvider: ChatViewProvider | undefined;
|
||||
|
||||
/** The webview's `{ drafts: [{ id, text }] }` snapshot, or null when it is not one. */
|
||||
function readDraftSnapshot(snapshot: unknown): Array<{ id: string; text: string }> | null {
|
||||
if (typeof snapshot !== 'object' || snapshot === null || !('drafts' in snapshot) || !Array.isArray(snapshot.drafts)) return null;
|
||||
const drafts: Array<{ id: string; text: string }> = [];
|
||||
for (const entry of snapshot.drafts) {
|
||||
if (typeof entry !== 'object' || entry === null || !('id' in entry) || typeof entry.id !== 'string') continue;
|
||||
const text = 'text' in entry && typeof entry.text === 'string' ? entry.text : '';
|
||||
drafts.push({ id: entry.id, text });
|
||||
}
|
||||
return drafts;
|
||||
}
|
||||
let agentManagerProvider: AgentManagerPanelProvider | undefined;
|
||||
let sessionEditorProvider: SessionEditorPanelProvider | undefined;
|
||||
let openCodeManager: OpenCodeManager | undefined;
|
||||
@@ -471,6 +484,97 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
);
|
||||
|
||||
// Comments are written where the code is: the thread opens on the selected
|
||||
// lines and stays there until the message is sent. The composer chips remain
|
||||
// the authoritative list, so the threads follow what the webview reports.
|
||||
const inlineCommentThreads = new InlineCommentThreads({
|
||||
submitDraft: async (payload) => {
|
||||
// A comment is written against code the user is reading, so it cannot
|
||||
// require them to have opened a chat first: with no session tab open,
|
||||
// one is opened, exactly as the toolbar's new-session button does.
|
||||
const panelId = sessionEditorProvider?.openWithLineComment(payload, activeSessionId);
|
||||
if (panelId) {
|
||||
return panelId;
|
||||
}
|
||||
// No session editor at all (provider gone): fall back to the sidebar
|
||||
// rather than accepting a comment that has nowhere to land.
|
||||
if (!(await revealChatViewForPayload())) {
|
||||
return null;
|
||||
}
|
||||
if (!chatViewProvider) {
|
||||
vscode.window.showWarningMessage(t('OpenChamber: Chat sidebar is not ready'));
|
||||
return null;
|
||||
}
|
||||
chatViewProvider.addLineComment(payload);
|
||||
return SIDEBAR_SURFACE_ID;
|
||||
},
|
||||
removeDraft: (draftId) => {
|
||||
// Every surface is told, because each webview holds its own draft store
|
||||
// and only the one actually holding the draft can drop it. Removal is
|
||||
// idempotent everywhere else.
|
||||
sessionEditorProvider?.removeLineComment(draftId);
|
||||
chatViewProvider?.removeLineComment(draftId);
|
||||
},
|
||||
reportUndelivered: () => {
|
||||
vscode.window.showWarningMessage(t('OpenChamber [Add Comment]: The comment never reached the chat and was discarded'));
|
||||
},
|
||||
avatar: vscode.Uri.joinPath(context.extensionUri, 'assets', 'app-icon.png'),
|
||||
strings: {
|
||||
threadLabel: ({ startLine, endLine }) => (startLine === endLine
|
||||
? t('Comment on line {0}', String(startLine))
|
||||
: t('Comment on lines {0}-{1}', String(startLine), String(endLine))),
|
||||
author: t('OpenChamber'),
|
||||
notSent: t('Not sent yet'),
|
||||
},
|
||||
});
|
||||
context.subscriptions.push(inlineCommentThreads);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.addLineComment', () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
vscode.window.showWarningMessage(t('OpenChamber [Add Comment]: No active editor'));
|
||||
return;
|
||||
}
|
||||
// Same rule the gutter `+` follows, so the two entry points cannot
|
||||
// disagree about where a comment is allowed.
|
||||
if (!inlineCommentThreads.canCommentOn(editor.document.uri)) {
|
||||
vscode.window.showWarningMessage(t('OpenChamber [Add Comment]: File is outside the workspace'));
|
||||
return;
|
||||
}
|
||||
inlineCommentThreads.openThread(editor.document.uri, editor.selection);
|
||||
})
|
||||
);
|
||||
|
||||
// Invoked by the thread's own Comment button, and by the gutter `+` flow,
|
||||
// which both arrive as a CommentReply carrying the typed text.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.submitLineComment', async (reply: vscode.CommentReply) => {
|
||||
await inlineCommentThreads.submitReply(reply);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.removeLineComment', (thread: vscode.CommentThread) => {
|
||||
inlineCommentThreads.removeThread(thread);
|
||||
})
|
||||
);
|
||||
|
||||
// The webview reports its whole draft list whenever it changes; the threads
|
||||
// follow it. Not contributed in package.json: internal wiring, not a command
|
||||
// a user should find in the palette.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.internal.inlineCommentsSync', (message: { snapshot: unknown; surfaceId: string }) => {
|
||||
// The snapshot crossed the webview boundary as JSON; the surface id was
|
||||
// stamped by the provider that received it, so an untagged snapshot
|
||||
// cannot be attributed and is ignored rather than applied to threads it
|
||||
// may know nothing about.
|
||||
const drafts = readDraftSnapshot(message.snapshot);
|
||||
if (!drafts || !message.surfaceId) return;
|
||||
inlineCommentThreads.reconcile(message.surfaceId, drafts);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.newSession', async (directory?: unknown) => {
|
||||
const candidates = resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []);
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DELIVERY_CONFIRMATION_TIMEOUT_MS, broadcastRemoval, canCommentOnDocument, drainPending, dropPendingById, nextDraftId, reconcileThreadFate, resolveCommentFilePath, resolveCommentOrigin, selectionLineRange, shouldAbandonUnconfirmed, shouldDisposeOnEmptyBody, snapshotOwnsThread } from './inlineCommentSelection';
|
||||
|
||||
const selection = (startLine: number, startChar: number, endLine: number, endChar: number) => ({
|
||||
start: { line: startLine, character: startChar },
|
||||
end: { line: endLine, character: endChar },
|
||||
});
|
||||
|
||||
describe('selectionLineRange', () => {
|
||||
test('a caret with no selection covers its own line', () => {
|
||||
assert.deepEqual(selectionLineRange(selection(0, 4, 0, 4)), { startLine: 1, endLine: 1 });
|
||||
});
|
||||
|
||||
test('a partial selection on one line covers that line', () => {
|
||||
assert.deepEqual(selectionLineRange(selection(11, 2, 11, 30)), { startLine: 12, endLine: 12 });
|
||||
});
|
||||
|
||||
test('a multi-line selection covers every line it touches', () => {
|
||||
assert.deepEqual(selectionLineRange(selection(4, 0, 7, 12)), { startLine: 5, endLine: 8 });
|
||||
});
|
||||
|
||||
test('stopping at the start of the next line does not count that line', () => {
|
||||
// Dragging past the end of line 12 lands at (12, 0) but shows nothing
|
||||
// there, so the comment is on line 12 alone.
|
||||
assert.deepEqual(selectionLineRange(selection(11, 0, 12, 0)), { startLine: 12, endLine: 12 });
|
||||
});
|
||||
|
||||
test('a selection ending at column 0 of its own line is still that line', () => {
|
||||
assert.deepEqual(selectionLineRange(selection(3, 0, 3, 0)), { startLine: 4, endLine: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextDraftId', () => {
|
||||
test('matches the shared store id format', () => {
|
||||
assert.match(nextDraftId(1735689600000, 0.123456789), /^icd-1735689600000-[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
test('different randomness yields different ids at the same instant', () => {
|
||||
assert.notEqual(nextDraftId(1, 0.5), nextDraftId(1, 0.9));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCommentFilePath', () => {
|
||||
test('an ordinary file keeps its path', () => {
|
||||
assert.equal(resolveCommentFilePath('/repo/src/app.ts', ''), '/repo/src/app.ts');
|
||||
});
|
||||
|
||||
test("a Source Control diff resolves to the query's real path", () => {
|
||||
// The original side of a diff is a `git:` document; its path is not a
|
||||
// file on disk, but the query names the file it came from.
|
||||
const query = JSON.stringify({ path: '/repo/src/app.ts', ref: '~' });
|
||||
assert.equal(resolveCommentFilePath('/repo/src/app.ts.git', query), '/repo/src/app.ts');
|
||||
});
|
||||
|
||||
test('a malformed query falls back to the URI path', () => {
|
||||
assert.equal(resolveCommentFilePath('/repo/src/app.ts', 'not json'), '/repo/src/app.ts');
|
||||
});
|
||||
|
||||
test('a query without a usable path falls back to the URI path', () => {
|
||||
assert.equal(resolveCommentFilePath('/repo/src/app.ts', JSON.stringify({ ref: '~' })), '/repo/src/app.ts');
|
||||
assert.equal(resolveCommentFilePath('/repo/src/app.ts', JSON.stringify({ path: ' ' })), '/repo/src/app.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCommentOrigin', () => {
|
||||
const diff = {
|
||||
original: 'git:/repo/src/app.ts?ref=HEAD',
|
||||
modified: 'file:///repo/src/app.ts',
|
||||
};
|
||||
|
||||
test('identifies both sides of the active diff', () => {
|
||||
assert.deepEqual(resolveCommentOrigin(diff.original, 'git', diff), { source: 'diff', side: 'original' });
|
||||
assert.deepEqual(resolveCommentOrigin(diff.modified, 'file', diff), { source: 'diff', side: 'modified' });
|
||||
});
|
||||
|
||||
test('uses the URI scheme when the active tab is unavailable', () => {
|
||||
assert.deepEqual(resolveCommentOrigin(diff.original, 'git'), { source: 'diff', side: 'original' });
|
||||
assert.deepEqual(resolveCommentOrigin(diff.modified, 'file'), { source: 'file' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('canCommentOnDocument', () => {
|
||||
test('a workspace file can take a comment', () => {
|
||||
assert.equal(canCommentOnDocument('file', true), true);
|
||||
});
|
||||
|
||||
test("a diff's original side can too, since it resolves to a workspace file", () => {
|
||||
assert.equal(canCommentOnDocument('git', true), true);
|
||||
});
|
||||
|
||||
test('a file outside the workspace cannot', () => {
|
||||
// The comment is filed against a workspace-relative path, so one written
|
||||
// elsewhere would name a file the composer cannot resolve.
|
||||
assert.equal(canCommentOnDocument('file', false), false);
|
||||
});
|
||||
|
||||
test('a comment editor cannot comment on itself', () => {
|
||||
assert.equal(canCommentOnDocument('comment', true), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drainPending', () => {
|
||||
test('every held comment is returned, in order', () => {
|
||||
// A second comment can be written while a panel is still booting.
|
||||
// Keeping only the newest silently dropped the first after its thread
|
||||
// had already reported success.
|
||||
const pending = ['first', 'second', 'third'];
|
||||
assert.deepEqual(drainPending(pending), ['first', 'second', 'third']);
|
||||
});
|
||||
|
||||
test('the hold is emptied, so a later flush delivers nothing twice', () => {
|
||||
const pending = ['only'];
|
||||
drainPending(pending);
|
||||
assert.deepEqual(pending, []);
|
||||
assert.deepEqual(drainPending(pending), []);
|
||||
});
|
||||
|
||||
test('an empty hold drains to nothing', () => {
|
||||
assert.deepEqual(drainPending([]), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropPendingById', () => {
|
||||
const held = () => [{ draftId: 'a' }, { draftId: 'b' }, { draftId: 'c' }];
|
||||
|
||||
test('a comment removed before it was ever delivered is dropped from the hold', () => {
|
||||
// Removing the thread while the panel is still booting used to leave the
|
||||
// payload queued, so the draft landed after the user had dropped it.
|
||||
const pending = held();
|
||||
assert.equal(dropPendingById(pending, 'b'), true);
|
||||
assert.deepEqual(pending.map((p) => p.draftId), ['a', 'c']);
|
||||
});
|
||||
|
||||
test('an id that is not held leaves the queue untouched', () => {
|
||||
const pending = held();
|
||||
assert.equal(dropPendingById(pending, 'zzz'), false);
|
||||
assert.deepEqual(pending.map((p) => p.draftId), ['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('an empty hold reports nothing dropped', () => {
|
||||
assert.equal(dropPendingById([], 'a'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldAbandonUnconfirmed', () => {
|
||||
test('a comment the composer never reported holding is given up on', () => {
|
||||
// The panel's webview never booted, or the message was dropped. The
|
||||
// thread would otherwise show "Not sent yet" forever for a comment that
|
||||
// cannot be sent and cannot be rewritten.
|
||||
assert.equal(shouldAbandonUnconfirmed(undefined), true);
|
||||
assert.equal(shouldAbandonUnconfirmed(false), true);
|
||||
});
|
||||
|
||||
test('a comment the composer confirmed holding is kept', () => {
|
||||
assert.equal(shouldAbandonUnconfirmed(true), false);
|
||||
});
|
||||
|
||||
test('the deadline outlasts the composer own wait for a directory', () => {
|
||||
// The webview waits up to 10s for a directory before filing the draft,
|
||||
// so a shorter deadline here would abandon comments that were fine.
|
||||
assert.ok(DELIVERY_CONFIRMATION_TIMEOUT_MS > 10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcastRemoval', () => {
|
||||
const surface = (draftIds: string[]) => {
|
||||
const notified: number[] = [];
|
||||
return {
|
||||
pendingLineComments: draftIds.map((draftId) => ({ draftId })),
|
||||
notify: () => notified.push(1),
|
||||
notified,
|
||||
};
|
||||
};
|
||||
|
||||
test('every surface is told, because only one of them holds the draft', () => {
|
||||
const a = surface([]);
|
||||
const b = surface([]);
|
||||
broadcastRemoval([a, b], 'icd-1');
|
||||
assert.equal(a.notified.length, 1);
|
||||
assert.equal(b.notified.length, 1);
|
||||
});
|
||||
|
||||
test('a comment still held for a booting surface is dropped from the hold', () => {
|
||||
// The notification alone would find nothing: an undelivered comment is
|
||||
// in no store yet, and would land after the user removed its thread.
|
||||
const holding = surface(['icd-1', 'icd-2']);
|
||||
broadcastRemoval([holding], 'icd-1');
|
||||
assert.deepEqual(holding.pendingLineComments.map((p) => p.draftId), ['icd-2']);
|
||||
});
|
||||
|
||||
test('surfaces holding nothing keep their queues intact', () => {
|
||||
const other = surface(['icd-9']);
|
||||
broadcastRemoval([other], 'icd-1');
|
||||
assert.deepEqual(other.pendingLineComments.map((p) => p.draftId), ['icd-9']);
|
||||
});
|
||||
|
||||
test('no surfaces at all is not an error', () => {
|
||||
assert.doesNotThrow(() => broadcastRemoval([], 'icd-1'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshotOwnsThread', () => {
|
||||
test('the surface holding the draft speaks for its thread', () => {
|
||||
assert.equal(snapshotOwnsThread('panel-a', 'panel-a'), true);
|
||||
});
|
||||
|
||||
test('another tab says nothing about this thread', () => {
|
||||
// Every webview has its own draft store, so a second session tab
|
||||
// reporting an empty list is not evidence that this comment is gone.
|
||||
// Before this rule, opening a tab disposed the other tab's threads.
|
||||
assert.equal(snapshotOwnsThread('panel-a', 'panel-b'), false);
|
||||
});
|
||||
|
||||
test('the sidebar does not speak for a panel, nor a panel for the sidebar', () => {
|
||||
assert.equal(snapshotOwnsThread('panel-a', 'sidebar'), false);
|
||||
assert.equal(snapshotOwnsThread('sidebar', 'panel-a'), false);
|
||||
});
|
||||
|
||||
test('a thread with no surface yet is owned by nobody', () => {
|
||||
assert.equal(snapshotOwnsThread(undefined, 'panel-a'), false);
|
||||
assert.equal(snapshotOwnsThread('', 'panel-a'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileThreadFate', () => {
|
||||
test('a draft absent from the very first snapshot is still in flight', () => {
|
||||
// Opening a session tab makes its webview publish before the comment
|
||||
// that opened it has landed. Treating that as a removal destroyed the
|
||||
// thread the user had just written.
|
||||
assert.equal(reconcileThreadFate(undefined, false), 'wait');
|
||||
});
|
||||
|
||||
test('a draft absent after having been seen was removed', () => {
|
||||
assert.equal(reconcileThreadFate(undefined, true), 'dispose');
|
||||
});
|
||||
|
||||
test('a draft present is shown, and counts as seen', () => {
|
||||
assert.equal(reconcileThreadFate('fix this', false), 'show');
|
||||
assert.equal(reconcileThreadFate('fix this', true), 'show');
|
||||
});
|
||||
|
||||
test('a draft emptied in the composer drops its thread', () => {
|
||||
assert.equal(reconcileThreadFate('', true), 'dispose');
|
||||
assert.equal(reconcileThreadFate(' ', false), 'dispose');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldDisposeOnEmptyBody', () => {
|
||||
test('blank and whitespace-only bodies are a cancel', () => {
|
||||
assert.equal(shouldDisposeOnEmptyBody(''), true);
|
||||
assert.equal(shouldDisposeOnEmptyBody(' \n\t '), true);
|
||||
});
|
||||
|
||||
test('any real text is kept', () => {
|
||||
assert.equal(shouldDisposeOnEmptyBody(' fix this '), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Pure selection and identity logic for editor comment threads. Kept free of
|
||||
* the `vscode` dependency so it can be unit tested in isolation.
|
||||
*/
|
||||
|
||||
export interface LineRange {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
}
|
||||
|
||||
interface SelectionLike {
|
||||
start: { line: number; character: number };
|
||||
end: { line: number; character: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* The 1-based inclusive line range a selection covers, as a reader sees it.
|
||||
*
|
||||
* Dragging to the start of the next line selects a trailing newline but shows
|
||||
* nothing on that line, so counting it would label a one-line comment as two
|
||||
* and send a range that does not match the highlight.
|
||||
*/
|
||||
export function selectionLineRange(selection: SelectionLike): LineRange {
|
||||
const startLine = selection.start.line + 1;
|
||||
const spansLines = selection.end.line > selection.start.line;
|
||||
const stopsAtLineStart = selection.end.character === 0 && spansLines;
|
||||
const endLine = (stopsAtLineStart ? selection.end.line - 1 : selection.end.line) + 1;
|
||||
return { startLine, endLine };
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft id in the shared store's format.
|
||||
*
|
||||
* The extension mints it so the thread and the composer chip agree on identity
|
||||
* without a round trip; the store accepts a caller-provided id for exactly this.
|
||||
*/
|
||||
export function nextDraftId(now: number, randomFraction: number): string {
|
||||
return `icd-${now}-${randomFraction.toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/** An empty body is a cancel, not a comment worth keeping on screen. */
|
||||
export function shouldDisposeOnEmptyBody(body: string): boolean {
|
||||
return body.trim().length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real file a comment target refers to.
|
||||
*
|
||||
* A diff opened from Source Control shows one pane per side, and the original
|
||||
* side is not a file on disk: it is a `git:` document carrying the real path in
|
||||
* its JSON query. Labelling a comment with the raw URI path would name a file
|
||||
* the composer cannot match, so the query wins when it has one.
|
||||
*
|
||||
* @param path the URI path (already query-free, as `fsPath` gives it)
|
||||
* @param query the URI query, empty for ordinary files
|
||||
*/
|
||||
export function resolveCommentFilePath(path: string, query: string): string {
|
||||
if (!query) return path;
|
||||
try {
|
||||
const parsed: { path?: string } = JSON.parse(query);
|
||||
return parsed.path?.trim() ? parsed.path : path;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
export type CommentOrigin = {
|
||||
source: 'diff' | 'file';
|
||||
side?: 'original' | 'modified';
|
||||
};
|
||||
|
||||
/** Preserves which side of an active diff supplied the selected code. */
|
||||
export function resolveCommentOrigin(
|
||||
uri: string,
|
||||
scheme: string,
|
||||
activeDiff?: { original: string; modified: string },
|
||||
): CommentOrigin {
|
||||
if (activeDiff?.original === uri) return { source: 'diff', side: 'original' };
|
||||
if (activeDiff?.modified === uri) return { source: 'diff', side: 'modified' };
|
||||
if (scheme === 'git') return { source: 'diff', side: 'original' };
|
||||
return { source: 'file' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a document can take a comment.
|
||||
*
|
||||
* Both entry points ask this, so the gutter `+` and the right-click command
|
||||
* agree about where commenting is allowed. A comment is filed against a
|
||||
* workspace-relative path, so one written outside the workspace would name a
|
||||
* file the composer cannot resolve.
|
||||
*/
|
||||
export function canCommentOnDocument(scheme: string, isInWorkspace: boolean): boolean {
|
||||
if (scheme === 'comment') return false;
|
||||
return isInWorkspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empties a hold of comments waiting on a webview that had not booted.
|
||||
*
|
||||
* A hold is a list, not a single slot: a user can write a second comment while
|
||||
* the panel is still starting, and keeping only the newest silently dropped the
|
||||
* first after its thread had already reported success.
|
||||
*/
|
||||
export function drainPending<T>(pending: T[]): T[] {
|
||||
return pending.splice(0, pending.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a held comment the user dropped before it was ever delivered.
|
||||
*
|
||||
* A comment waiting on a booting webview is in no store yet, so asking that
|
||||
* webview to remove it finds nothing. Without dropping the hold too, the draft
|
||||
* would land after the user had already removed its thread.
|
||||
*
|
||||
* @returns whether a held comment was dropped
|
||||
*/
|
||||
export function dropPendingById<T extends { draftId?: string }>(pending: T[], draftId: string): boolean {
|
||||
const index = pending.findIndex((entry) => entry.draftId === draftId);
|
||||
if (index < 0) return false;
|
||||
pending.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a submitted comment may go unconfirmed before it is given up on.
|
||||
*
|
||||
* Long enough to outlast a cold webview boot plus the composer's own wait for a
|
||||
* directory, short enough that a thread does not sit there promising to send
|
||||
* something that never will.
|
||||
*/
|
||||
export const DELIVERY_CONFIRMATION_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Whether a submitted comment should be abandoned once its deadline passes.
|
||||
*
|
||||
* Confirmation means the composer reported holding the draft. Without it the
|
||||
* comment reached no store: the panel's webview never booted, or the message
|
||||
* was dropped. Keeping the thread would show "Not sent yet" forever, for a
|
||||
* comment that cannot be sent and cannot be rewritten — only deleted.
|
||||
*/
|
||||
export function shouldAbandonUnconfirmed(confirmed: boolean | undefined): boolean {
|
||||
return !confirmed;
|
||||
}
|
||||
|
||||
/** A chat surface that may be holding, or showing, a comment draft. */
|
||||
export interface RemovalTarget {
|
||||
/** Comments still waiting on this surface's webview to boot. */
|
||||
pendingLineComments: Array<{ draftId?: string }>;
|
||||
/** Asks this surface's webview to drop the draft from its store. */
|
||||
notify: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells every surface to drop a comment, wherever it currently lives.
|
||||
*
|
||||
* Each webview owns its own draft store, so the one holding the draft cannot be
|
||||
* known from here; every surface is told and the rest no-op. The hold is cleared
|
||||
* before notifying, because a comment that has not been delivered yet is in no
|
||||
* store for the notification to find, and would otherwise arrive afterwards as a
|
||||
* chip the user had already dropped.
|
||||
*/
|
||||
export function broadcastRemoval(targets: Iterable<RemovalTarget>, draftId: string): void {
|
||||
for (const target of targets) {
|
||||
dropPendingById(target.pendingLineComments, draftId);
|
||||
target.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a draft snapshot is authoritative for a thread.
|
||||
*
|
||||
* Every webview — the sidebar and each session tab — runs its own draft store
|
||||
* and publishes its whole list. Only the surface that accepted a comment knows
|
||||
* whether it still holds it; to any other surface the draft simply never
|
||||
* existed. Letting a foreign snapshot decide disposed threads that were alive
|
||||
* and about to be sent, which is what opening a second tab used to do.
|
||||
*/
|
||||
export function snapshotOwnsThread(threadSurfaceId: string | undefined, snapshotSurfaceId: string): boolean {
|
||||
return Boolean(threadSurfaceId) && threadSurfaceId === snapshotSurfaceId;
|
||||
}
|
||||
|
||||
/** What a draft-list snapshot says should happen to one editor thread. */
|
||||
export type ThreadFate = 'wait' | 'dispose' | 'show';
|
||||
|
||||
/**
|
||||
* Decides a thread's fate from the composer's current draft list.
|
||||
*
|
||||
* `confirmed` records whether the composer has ever reported holding this
|
||||
* draft. Until it has, absence means the delivery is still in flight, not that
|
||||
* the comment was removed: opening a session tab produces a first snapshot
|
||||
* describing the composer as it was before the comment that opened it arrived.
|
||||
*
|
||||
* @param text the draft's text in the snapshot, or undefined when absent
|
||||
*/
|
||||
export function reconcileThreadFate(text: string | undefined, confirmed: boolean): ThreadFate {
|
||||
if (text === undefined) return confirmed ? 'dispose' : 'wait';
|
||||
if (shouldDisposeOnEmptyBody(text)) return 'dispose';
|
||||
return 'show';
|
||||
}
|
||||
Reference in New Issue
Block a user