blob: 05f931ce9ea1d62c3bc5f251a1d5ed3b1f3944a5 [file] [edit]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Platform from '../../core/platform/platform.js';
import type * as SDK from '../../core/sdk/sdk.js';
import type * as ChangeTracker from '../change_tracker/change_tracker.js';
import type * as CommentManager from '../comment_manager/comment_manager.js';
type Tracker = ChangeTracker.ChangeTracker.ChangeTracker|undefined;
/**
* Maximum number of characters of page controlled data (tag names, attribute names and values,
* text node values and HTML) that a change description embeds. The inspected page fully controls
* these strings and `Changed HTML from ... to ...` in particular can otherwise hold on to the
* entire subtree of an element.
*/
export const MAX_VALUE_LENGTH = 100;
/** Shortens page controlled data so that both its start and its end stay recognizable. */
function trimValue(value: string): string {
return Platform.StringUtilities.trimMiddle(value, MAX_VALUE_LENGTH);
}
function buildAnchor(node: SDK.DOMModel.DOMNode,
selector = 'element'): CommentManager.CommentManager.CommentAnchorSignature {
return {
vePath: 'Panel: elements > Tree: elements > TreeItem',
textSignature: selector,
node: {
backendNodeId: node.backendNodeId(),
targetId: node.domModel().target().id(),
},
};
}
/**
* Parses a raw attribute string (e.g. 'class="btn"', 'data-id=123', or 'disabled')
* into its attribute name and unquoted value for single-attribute inline edits.
*/
function parseAttributeText(text: string): {name: string, value: string} {
const trimmed = text.trim();
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) {
return {name: trimmed, value: ''};
}
const name = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
if (value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')))) {
value = value.slice(1, -1);
}
return {name, value};
}
function describeAddAttribute(attributeName: string, value?: string): string {
if (value !== undefined && value !== '') {
return `Added attribute ${trimValue(attributeName)}="${trimValue(value)}"`;
}
return `Added attribute "${trimValue(attributeName)}"`;
}
function describeRemoveAttribute(attributeName: string): string {
return `Removed attribute "${trimValue(attributeName)}"`;
}
/**
* Describes a modification of an existing attribute, which covers renaming the attribute, changing
* its value, or both at once. `oldValue` is `undefined` if the previous value is unknown.
*/
function describeAttributeChange(
oldAttributeName: string,
oldValue: string|undefined,
newAttributeName: string,
newValue: string,
): string {
if (oldAttributeName !== newAttributeName) {
if (oldValue !== undefined && oldValue !== newValue) {
return `Renamed attribute "${trimValue(oldAttributeName)}"="${trimValue(oldValue)}" to "${
trimValue(newAttributeName)}"="${trimValue(newValue)}"`;
}
return `Renamed attribute "${trimValue(oldAttributeName)}" to "${trimValue(newAttributeName)}"`;
}
if (oldValue !== undefined) {
return `Changed attribute "${trimValue(newAttributeName)}" from "${trimValue(oldValue)}" to "${
trimValue(newValue)}"`;
}
return `Changed attribute "${trimValue(newAttributeName)}" to "${trimValue(newValue)}"`;
}
export interface AttributeEdit {
/** Name of the edited attribute, or an empty string when a new attribute is added. */
attributeName: string;
/** Raw attribute text before the edit, if there was any. */
oldText: string|null;
/** Raw attribute text the user committed. Empty when the attribute is removed. */
newText: string;
}
function describeAttributeEdit({attributeName, oldText, newText}: AttributeEdit): string {
const parsedNew = parseAttributeText(newText);
const parsedOld = oldText !== null ? parseAttributeText(oldText) : undefined;
if (!newText.trim()) {
return describeRemoveAttribute(attributeName.trim());
}
if (!attributeName.trim()) {
return describeAddAttribute(parsedNew.name, parsedNew.value);
}
return describeAttributeChange(
attributeName.trim(),
parsedOld?.value,
parsedNew.name || attributeName.trim(),
parsedNew.value,
);
}
/**
* These strings are for the consumption by AI agents, they don't need to be translated.
*/
function describeTagNameEdit(oldTagName: string, newTagName: string): string {
return `Renamed tag from <${trimValue(oldTagName)}> to <${trimValue(newTagName)}>`;
}
function describeTextNodeEdit(oldText: string, newText: string): string {
return `Changed text from "${trimValue(oldText)}" to "${trimValue(newText)}"`;
}
function describeNodeRemoval(tagName: string): string {
return `Removed node <${trimValue(tagName)}>`;
}
function describeHTMLEdit(oldValue?: string, newValue?: string): string {
if (oldValue !== undefined && newValue !== undefined) {
return `Changed HTML from "${trimValue(oldValue)}" to "${trimValue(newValue)}"`;
}
if (newValue !== undefined) {
return `Changed HTML to "${trimValue(newValue)}"`;
}
return 'Edited HTML';
}
function describeVisibilityToggle(tagName: string, hidden: boolean): string {
return hidden ? `Hid element <${trimValue(tagName)}>` : `Unhid element <${trimValue(tagName)}>`;
}
function describeNodeDuplication(tagName: string): string {
return `Duplicated node <${trimValue(tagName)}>`;
}
function describeNodeMove(tagName: string, directionUp: boolean): string {
return `Moved node <${trimValue(tagName)}> ${directionUp ? 'up' : 'down'}`;
}
function describeNodeDrop(tagName: string): string {
return `Moved node <${trimValue(tagName)}> via drag and drop`;
}
function describeNodePaste(tagName: string, isCut: boolean): string {
return isCut ? `Pasted (moved) node <${trimValue(tagName)}>` : `Pasted node <${trimValue(tagName)}>`;
}
export function trackAttributeEdit(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined,
edit: AttributeEdit): void {
if (!tracker?.isTracking) {
return;
}
tracker.trackChange(describeAttributeEdit(edit), buildAnchor(node, selector));
}
export function trackTagNameEdit(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined,
oldTagName: string, newTagName: string): void {
if (!tracker?.isTracking) {
return;
}
tracker.trackChange(describeTagNameEdit(oldTagName, newTagName), buildAnchor(node, selector));
}
export function trackTextNodeEdit(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined,
oldText: string, newText: string): void {
if (!tracker?.isTracking) {
return;
}
tracker.trackChange(describeTextNodeEdit(oldText, newText), buildAnchor(node, selector));
}
export function trackNodeRemoval(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined): void {
if (!tracker?.isTracking) {
return;
}
tracker.trackChange(describeNodeRemoval(node.nodeName().toLowerCase()), buildAnchor(node, selector));
}
export function trackHTMLEdit(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined,
oldValue: string, newValue: string): void {
if (!tracker?.isTracking) {
return;
}
tracker.trackChange(describeHTMLEdit(oldValue, newValue), buildAnchor(node, selector));
}
export function trackVisibilityToggle(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined,
hidden: boolean): void {
if (!tracker?.isTracking) {
return;
}
tracker.trackChange(describeVisibilityToggle(node.nodeName().toLowerCase(), hidden), buildAnchor(node, selector));
}
export function trackNodeDuplication(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined): void {
if (!tracker?.isTracking) {
return;
}
tracker.trackChange(describeNodeDuplication(node.nodeName().toLowerCase()), buildAnchor(node, selector));
}
export function trackNodeMove(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined,
directionUp: boolean): void {
if (!tracker?.isTracking) {
return;
}
tracker.trackChange(describeNodeMove(node.nodeName().toLowerCase(), directionUp), buildAnchor(node, selector));
}
export function trackNodeDrop(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined): void {
if (!tracker?.isTracking) {
return;
}
tracker.trackChange(describeNodeDrop(node.nodeName().toLowerCase()), buildAnchor(node, selector));
}
export function trackNodePaste(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined,
isCut: boolean): void {
if (!tracker?.isTracking) {
return;
}
tracker.trackChange(describeNodePaste(node.nodeName().toLowerCase(), isCut), buildAnchor(node, selector));
}