[Patch agent] Add saving to workspace

If all modified files have a binding to a file on disk, allow
saving the changes to disk.

Bug: 406217277
Change-Id: Ib2f0adffc885b467ba885eb00d7b8765a0ba2d9b
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6403240
Commit-Queue: Wolfgang Beyer <wolfi@chromium.org>
Reviewed-by: Ergün Erdoğmuş <ergunsh@chromium.org>
Auto-Submit: Wolfgang Beyer <wolfi@chromium.org>
This commit is contained in:
Wolfgang Beyer
2025-03-28 03:24:43 -07:00
committed by Devtools-frontend LUCI CQ
parent 48963ad553
commit 32caf01d79
3 changed files with 78 additions and 34 deletions
@@ -12,7 +12,6 @@ import {
cleanup,
createPatchWidget,
createPatchWidgetWithDiffView,
createTestFilesystem,
initializePersistenceImplForTests,
MockAidaAbortError,
mockAidaClient,
@@ -20,7 +19,7 @@ import {
} from '../../testing/AiAssistanceHelpers.js';
import {updateHostConfig} from '../../testing/EnvironmentHelpers.js';
import {describeWithMockConnection} from '../../testing/MockConnection.js';
import {createContentProviderUISourceCode} from '../../testing/UISourceCodeHelpers.js';
import {createContentProviderUISourceCode, createFileSystemUISourceCode} from '../../testing/UISourceCodeHelpers.js';
import * as AiAssistance from './ai_assistance.js';
@@ -37,7 +36,7 @@ describeWithMockConnection('PatchWidget', () => {
cleanup();
});
describe('applyToWorkspace', () => {
describe('applyToPageTree', () => {
beforeEach(() => {
createContentProviderUISourceCode({
url: Platform.DevToolsPath.urlString`file://test/index.html`,
@@ -129,7 +128,7 @@ describeWithMockConnection('PatchWidget', () => {
});
});
it('should show FRE dialog on applyToWorkspace click if the setting is false', async () => {
it('should show FRE dialog on applyToPageTree click if the setting is false', async () => {
Common.Settings.moduleSetting('ai-assistance-patching-fre-completed').set(false);
const {view, widget} = await createPatchWidget();
widget.changeSummary = 'body { background-color: red; }';
@@ -139,7 +138,7 @@ describeWithMockConnection('PatchWidget', () => {
assert.isTrue(showFreDialogStub.called, 'Expected FreDialog to be shown but it\'s not shown');
});
it('should not show FRE dialog on applyToWorkspace click if the setting is true', async () => {
it('should not show FRE dialog on applyToPageTree click if the setting is true', async () => {
Common.Settings.moduleSetting('ai-assistance-patching-fre-completed').set(true);
const {view, widget} = await createPatchWidget();
widget.changeSummary = 'body { background-color: red; }';
@@ -166,7 +165,7 @@ Files:
* /index.html`);
});
it('should show error state when applyToWorkspace fails', async () => {
it('should show error state when applyToPageTree fails', async () => {
const {view, widget} = await createPatchWidget({aidaClient: mockAidaClient([[MockAidaFetchError]])});
widget.changeSummary = 'body { background-color: red; }';
@@ -188,6 +187,7 @@ Files:
});
describe('diff view', () => {
const origContent = 'window.foo = () => "foo";\n';
let fileSystemUISourceCode: Workspace.UISourceCode.UISourceCode;
let commitWorkingCopyStub:
sinon.SinonStub<Parameters<typeof Workspace.UISourceCode.UISourceCode.prototype.commitWorkingCopy>>;
@@ -195,8 +195,6 @@ Files:
sinon.SinonStub<Parameters<typeof Workspace.UISourceCode.UISourceCode.prototype.resetWorkingCopy>>;
beforeEach(() => {
fileSystemUISourceCode = createTestFilesystem('file://test').uiSourceCode;
Common.Settings.Settings.instance().createSetting('ai-assistance-patching-selected-project-id', 'file://test');
updateHostConfig({
devToolsFreestyler: {
enabled: true,
@@ -204,12 +202,32 @@ Files:
},
});
const url = Platform.DevToolsPath.urlString`https://example.com/script.js`;
createContentProviderUISourceCode({
url,
content: origContent,
mimeType: 'text/javascript',
projectType: Workspace.Workspace.projectTypes.Network,
metadata: new Workspace.UISourceCode.UISourceCodeMetadata(null, origContent.length),
});
commitWorkingCopyStub =
sinon.stub(Workspace.UISourceCode.UISourceCode.prototype, 'commitWorkingCopy').callThrough();
resetWorkingCopyStub =
sinon.stub(Workspace.UISourceCode.UISourceCode.prototype, 'resetWorkingCopy').callThrough();
});
const createBoundFileSystemUISourceCode = () => {
const localUrl = Platform.DevToolsPath.urlString`file:///var/www/script.js`;
({uiSourceCode: fileSystemUISourceCode} = createFileSystemUISourceCode({
url: localUrl,
mimeType: 'text/javascript',
content: origContent,
autoMapping: true,
metadata: new Workspace.UISourceCode.UISourceCodeMetadata(null, origContent.length),
}));
};
it('on apply should call handle function and stash changes', async () => {
const {
view,
@@ -223,22 +241,31 @@ Files:
assert.isTrue(changeManager.stashChanges.calledOnce);
});
it('save all should commit the working copy of the changed UI codes to the disk and render savedToDisk view',
async () => {
const {view, widget} = await createPatchWidgetWithDiffView();
const changeManager = sinon.createStubInstance(AiAssistanceModel.ChangeManager);
widget.changeManager = changeManager;
fileSystemUISourceCode.setWorkingCopy('working copy');
it('"save to workspace" is not available if there is no matching file system mapping', async () => {
const {view, widget} = await createPatchWidgetWithDiffView();
const changeManager = sinon.createStubInstance(AiAssistanceModel.ChangeManager);
widget.changeManager = changeManager;
assert.isUndefined(view.input.onSaveToWorkspace);
});
view.input.onSaveAll();
const nextInput = await view.nextInput;
it('"save to workspace" should commit the working copy of the files to disk and update the view', async () => {
createBoundFileSystemUISourceCode();
fileSystemUISourceCode.setWorkingCopy('working copy');
const {view, widget} = await createPatchWidgetWithDiffView();
const changeManager = sinon.createStubInstance(AiAssistanceModel.ChangeManager);
widget.changeManager = changeManager;
assert.isTrue(nextInput.savedToDisk);
assert.isTrue(commitWorkingCopyStub.called, 'Expected commitWorkingCopy to be called but it is not called');
assert.isTrue(changeManager.dropStashedChanges.calledOnce);
});
assert.isDefined(view.input.onSaveToWorkspace);
view.input.onSaveToWorkspace();
const nextInput = await view.nextInput;
assert.isTrue(nextInput.savedToDisk);
assert.isTrue(commitWorkingCopyStub.called, 'Expected commitWorkingCopy to be called but it is not called');
assert.isTrue(changeManager.dropStashedChanges.calledOnce);
});
it('discard should discard the working copy and render the view without patchSuggestion', async () => {
createBoundFileSystemUISourceCode();
const {view, widget} = await createPatchWidgetWithDiffView();
const changeManager = sinon.createStubInstance(AiAssistanceModel.ChangeManager);
widget.changeManager = changeManager;
+30 -13
View File
@@ -13,6 +13,7 @@ import * as i18n from '../../core/i18n/i18n.js';
import type * as Platform from '../../core/platform/platform.js';
import * as Root from '../../core/root/root.js';
import * as AiAssistanceModel from '../../models/ai_assistance/ai_assistance.js';
import * as Persistence from '../../models/persistence/persistence.js';
import * as WorkspaceDiff from '../../models/workspace_diff/workspace_diff.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as UI from '../../ui/legacy/legacy.js';
@@ -48,9 +49,9 @@ const UIStringsNotTranslate = {
*/
discard: 'Discard',
/**
*@description Button text to save all the suggested changes to file system
*@description Button text to save all the suggested changes to the file system
*/
saveAll: 'Save all',
saveToWorkspace: 'Save to workspace',
/**
*@description Header text after the user saved the changes to the disk.
*/
@@ -143,7 +144,7 @@ export interface ViewInput {
onApplyToPageTree: () => void;
onCancel: () => void;
onDiscard: () => void;
onSaveAll: () => void;
onSaveToWorkspace?: () => void;
}
export interface ViewOutput {
@@ -167,6 +168,7 @@ export class PatchWidget extends UI.Widget.Widget {
#noLogging: boolean; // Whether the enterprise setting is `ALLOW_WITHOUT_LOGGING` or not.
#patchSuggestionState = PatchSuggestionState.INITIAL;
#workspaceDiff = WorkspaceDiff.WorkspaceDiff.workspaceDiff();
#persistence = Persistence.Persistence.PersistenceImpl.instance();
constructor(element?: HTMLElement, view?: View, opts?: {
aidaClient: Host.AidaClient.AidaClient,
@@ -278,12 +280,14 @@ export class PatchWidget extends UI.Widget.Widget {
.variant=${Buttons.Button.Variant.OUTLINED}>
${lockedString(UIStringsNotTranslate.discard)}
</devtools-button>
<devtools-button
@click=${input.onSaveAll}
.jslogContext=${'patch-widget.save-all'}
.variant=${Buttons.Button.Variant.PRIMARY}>
${lockedString(UIStringsNotTranslate.saveAll)}
</devtools-button>
${input.onSaveToWorkspace ? html`
<devtools-button
@click=${input.onSaveToWorkspace}
.jslogContext=${'patch-widget.save-to-workspace'}
.variant=${Buttons.Button.Variant.PRIMARY}>
${lockedString(UIStringsNotTranslate.saveToWorkspace)}
</devtools-button>
` : nothing}
</div>
</div>
`;
@@ -377,7 +381,7 @@ export class PatchWidget extends UI.Widget.Widget {
this.#applyPatchAbortController?.abort();
},
onDiscard: this.#onDiscard.bind(this),
onSaveAll: this.#onSaveAll.bind(this),
onSaveToWorkspace: this.#canSaveToWorkspace() ? this.#onSaveToWorkspace.bind(this) : undefined,
},
this.#viewOutput, this.contentElement);
}
@@ -487,10 +491,23 @@ ${processedFiles.map(filename => `* ${filename}`).join('\n')}`;
this.requestUpdate();
}
#onSaveAll(): void {
// TODO: What should we do for the inspector stylesheet?
#canSaveToWorkspace(): boolean {
if (this.#patchSuggestionState !== PatchSuggestionState.SUCCESS) {
return false;
}
// TODO(crbug.com/406699819): investigate why the inspector-stylesheet shows up here
const filteredModifiedUISourceCodes =
this.#workspaceDiff.modifiedUISourceCodes().filter(sourceCode => sourceCode.origin() !== 'inspector://');
return filteredModifiedUISourceCodes.length > 0 &&
filteredModifiedUISourceCodes.every(sourceCode => this.#persistence.binding(sourceCode));
}
#onSaveToWorkspace(): void {
this.#workspaceDiff.modifiedUISourceCodes().forEach(modifiedUISourceCode => {
modifiedUISourceCode.commitWorkingCopy();
const binding = this.#persistence.binding(modifiedUISourceCode);
if (binding) {
binding.fileSystem.commitWorkingCopy();
}
});
this.#savedToDisk = true;
@@ -2633,7 +2633,7 @@ export const knownContextValues = new Set([
'passive',
'paste',
'patch-widget.discard',
'patch-widget.save-all',
'patch-widget.save-to-workspace',
'path',
'pattern',
'pause',