refactor(devtools): implement settings store (#62429)

Introduces a set of services tasked with saving user settings.

PR Close #62429
This commit is contained in:
hawkgs
2025-07-15 15:02:00 +03:00
committed by Kristiyan Kostadinov
parent 986b0b1fc0
commit 1cdb54559d
19 changed files with 519 additions and 20 deletions
@@ -15,4 +15,7 @@ export abstract class ApplicationOperations {
abstract inspect(directivePosition: DirectivePosition, objectPath: string[], target: Frame): void;
abstract inspectSignal(position: SignalNodePosition, target: Frame): void;
abstract viewSourceFromRouter(name: string, type: string, target: Frame): void;
abstract setStorageItems(items: {[key: string]: unknown}): Promise<void>;
abstract getStorageItems(items: string[]): Promise<{[key: string]: unknown}>;
abstract removeStorageItems(items: string[]): Promise<void>;
}
@@ -1,6 +1,6 @@
load("//devtools/tools:defaults.bzl", "ng_project")
package(default_visibility = ["//visibility:public"])
package(default_visibility = ["//devtools:__subpackages__"])
ng_project(
name = "window",
@@ -9,3 +9,14 @@ ng_project(
"//:node_modules/@angular/core",
],
)
ng_project(
name = "settings",
srcs = ["settings_provider.ts"],
deps = [
"//:node_modules/@angular/core",
"//devtools/projects/ng-devtools/src/lib/application-operations",
"//devtools/projects/ng-devtools/src/lib/application-services:settings",
"//devtools/projects/ng-devtools/src/lib/application-services:settings_store",
],
)
@@ -0,0 +1,29 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {provideAppInitializer, inject, Provider, EnvironmentProviders} from '@angular/core';
import {SETTINGS_STORE_KEY, SettingsStore} from '../application-services/settings_store';
import {ApplicationOperations} from '../application-operations';
import {Settings} from '../application-services/settings';
export function provideSettings(): (Provider | EnvironmentProviders)[] {
let savedSettings: {[key: string]: unknown};
return [
provideAppInitializer(async () => {
const appOperations = inject(ApplicationOperations);
const keyedItem = await appOperations.getStorageItems([SETTINGS_STORE_KEY]);
savedSettings = (keyedItem[SETTINGS_STORE_KEY] ?? {}) as {[key: string]: unknown};
}),
{
provide: SettingsStore,
useFactory: () => new SettingsStore(savedSettings),
},
Settings,
];
}
@@ -1,6 +1,6 @@
load("//devtools/tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_test_library")
package(default_visibility = ["//visibility:public"])
package(default_visibility = ["//devtools:__subpackages__"])
ng_project(
name = "browser_styles",
@@ -34,18 +34,39 @@ ng_project(
],
)
ng_project(
name = "settings_store",
srcs = ["settings_store.ts"],
deps = [
"//devtools/projects/ng-devtools/src/lib/application-operations",
"//packages/core",
],
)
ng_project(
name = "settings",
srcs = ["settings.ts"],
deps = [
":settings_store",
"//packages/core",
],
)
ts_test_library(
name = "test_application_services_lib",
srcs = glob(["*_spec.ts"]),
deps = [
":browser_styles",
":frame_manager",
":settings_store",
":theme",
"//:node_modules/@angular/cdk",
"//:node_modules/@angular/common",
"//:node_modules/@angular/core",
"//devtools/projects/ng-devtools/src/lib/application-environment",
"//devtools/projects/ng-devtools/src/lib/application-providers:window",
"//devtools/projects/ng-devtools/src/lib/application-services/test-utils:app_operations_mock",
"//devtools/projects/ng-devtools/src/lib/application-services/test-utils:settings_store_mock",
"//devtools/projects/protocol",
],
)
@@ -0,0 +1,20 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {inject} from '@angular/core';
import {SettingsStore} from './settings_store';
export class Settings {
private readonly settingsStore = inject(SettingsStore);
readonly dummy = this.settingsStore.create({
key: 'dummy',
category: 'general',
initialValue: true,
});
}
@@ -0,0 +1,48 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {effect, inject, Injector, signal, WritableSignal} from '@angular/core';
import {ApplicationOperations} from '../application-operations';
export const SETTINGS_STORE_KEY = 'ng-dt-settings-v1';
/** Provides an API for storing and preserving settings values. */
export class SettingsStore {
private readonly appOperations = inject(ApplicationOperations);
private readonly injector = inject(Injector);
private readonly signals = new Map<string, WritableSignal<unknown>>();
constructor(private data: {[key: string]: unknown}) {}
/**
* Create a settings value a provided key, as a writable signal.
* If the item doesn't exist, a new one will be created.
* Updates to the signal value are automatically stored in the storage.
*/
create<T>(config: {key: string; category: string; initialValue: T}): WritableSignal<T> {
const storeKey = `${config.key}@${config.category}`;
const existing = this.signals.get(storeKey);
if (existing) {
return existing as WritableSignal<T>;
}
const initialValue = storeKey in this.data ? (this.data[storeKey] as T) : config.initialValue;
const value = signal<T>(initialValue);
this.signals.set(storeKey, value);
effect(
() => {
this.data[storeKey] = value();
this.appOperations.setStorageItems({[SETTINGS_STORE_KEY]: this.data});
},
{injector: this.injector},
);
return value;
}
}
@@ -0,0 +1,146 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {TestBed} from '@angular/core/testing';
import {SettingsStore} from './settings_store';
import {ApplicationOperations} from '../application-operations';
import {AppOperationsMock} from './test-utils/app_operations_mock';
import {ApplicationRef} from '@angular/core';
describe('SettingsStore', () => {
let settingsStore: SettingsStore;
let getStoredSettings: () => {[key: string]: unknown};
beforeEach(() => {
const appOperationsMock = new AppOperationsMock();
TestBed.configureTestingModule({
providers: [
{provide: ApplicationOperations, useValue: appOperationsMock},
{
provide: SettingsStore,
useFactory: () => new SettingsStore({}),
},
],
});
settingsStore = TestBed.inject(SettingsStore);
getStoredSettings = () => appOperationsMock.getStoredSettings();
});
it('should return a settings item with an initial value', async () => {
const item = settingsStore.create({
key: 'item',
category: 'test',
initialValue: 'foo',
});
expect(item()).toEqual('foo');
});
it('should set a settings item value', async () => {
const value = settingsStore.create({
key: 'item',
category: 'test',
initialValue: 'foo',
});
expect(value()).toEqual('foo');
value.set('bar');
expect(value()).toBe('bar');
await TestBed.inject(ApplicationRef).whenStable();
TestBed.tick();
expect(getStoredSettings()['item@test']).toEqual('bar');
});
it('should set multiple values to a single settings item', async () => {
const value = settingsStore.create({
key: 'item',
category: 'test',
initialValue: 'foo',
});
expect(value()).toEqual('foo');
value.set('bar');
await TestBed.inject(ApplicationRef).whenStable();
TestBed.tick();
expect(getStoredSettings()['item@test']).toEqual('bar');
value.set('baz');
await TestBed.inject(ApplicationRef).whenStable();
TestBed.tick();
expect(getStoredSettings()['item@test']).toEqual('baz');
});
it('should set values to multiple settings items', async () => {
const first = settingsStore.create({
key: 'first',
category: 'test',
initialValue: 'not_set',
});
const second = settingsStore.create({
key: 'second',
category: 'test',
initialValue: 'not_set',
});
expect(first()).toEqual('not_set');
expect(second()).toEqual('not_set');
first.set('1st');
second.set('2nd');
await TestBed.inject(ApplicationRef).whenStable();
TestBed.tick();
expect(getStoredSettings()['first@test']).toEqual('1st');
expect(getStoredSettings()['second@test']).toEqual('2nd');
});
it('should keep in sync multiple instances of the same settings item', async () => {
const foo = settingsStore.create({
key: 'item',
category: 'test',
initialValue: 'foo',
});
const bar = settingsStore.create({
key: 'item',
category: 'test',
initialValue: 'bar',
});
expect(foo()).toEqual('foo');
expect(bar()).toEqual('foo');
bar.set('baz');
expect(foo()).toEqual('baz');
expect(bar()).toEqual('baz');
await TestBed.inject(ApplicationRef).whenStable();
TestBed.tick();
expect(getStoredSettings()['item@test']).toEqual('baz');
});
it('should keep the latest signal value', async () => {
const value = settingsStore.create({
key: 'item',
category: 'test',
initialValue: 'foo',
});
expect(value()).toEqual('foo');
value.set('bar');
value.set('baz');
expect(value()).toBe('baz');
await TestBed.inject(ApplicationRef).whenStable();
TestBed.tick();
expect(getStoredSettings()['item@test']).toEqual('baz');
});
});
@@ -0,0 +1,27 @@
load("//devtools/tools:defaults.bzl", "ts_project")
package(default_visibility = ["//devtools:__subpackages__"])
ts_project(
name = "settings_store_mock",
srcs = [
"settings_store_mock.ts",
],
deps = [
"//:node_modules/@angular/core",
"//devtools/projects/ng-devtools/src/lib/application-services:settings_store",
],
)
ts_project(
name = "app_operations_mock",
srcs = [
"app_operations_mock.ts",
],
deps = [
"//devtools/projects/ng-devtools/src/lib/application-environment",
"//devtools/projects/ng-devtools/src/lib/application-operations",
"//devtools/projects/ng-devtools/src/lib/application-services:settings_store",
"//devtools/projects/protocol",
],
)
@@ -0,0 +1,64 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {DirectivePosition, ElementPosition, SignalNodePosition} from '../../../../../protocol';
import {Frame} from '../../application-environment';
import {ApplicationOperations} from '../../application-operations';
import {SETTINGS_STORE_KEY} from '../settings_store';
export class AppOperationsMock extends ApplicationOperations {
private storage: {[key: string]: unknown} = {};
/** Helper method – gives access to stored settings */
getStoredSettings() {
return this.storage[SETTINGS_STORE_KEY] as {[key: string]: unknown};
}
override async setStorageItems(items: {[key: string]: unknown}): Promise<void> {
this.storage = {
...this.storage,
...items,
};
}
override async getStorageItems(items: string[]): Promise<{[key: string]: unknown}> {
const obj: {[key: string]: unknown} = {};
for (const item of items) {
obj[item] = this.storage[item];
}
return obj;
}
override removeStorageItems(items: string[]): Promise<void> {
throw new Error('Method not implemented.');
}
override viewSource(position: ElementPosition, target: Frame, directiveIndex?: number): void {
throw new Error('Method not implemented.');
}
override selectDomElement(position: ElementPosition, target: Frame): void {
throw new Error('Method not implemented.');
}
override inspect(
directivePosition: DirectivePosition,
objectPath: string[],
target: Frame,
): void {
throw new Error('Method not implemented.');
}
override inspectSignal(position: SignalNodePosition, target: Frame): void {
throw new Error('Method not implemented.');
}
override viewSourceFromRouter(name: string, type: string, target: Frame): void {
throw new Error('Method not implemented.');
}
}
@@ -0,0 +1,25 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Provider, signal} from '@angular/core';
import {SettingsStore} from '../settings_store';
export class SettingsStoreMock {
private readonly signals = new Map<string, unknown>();
get(key: string, initialValue: unknown) {
const value = this.signals.get(key) ?? signal(initialValue);
this.signals.set(key, value);
return value;
}
}
export const SETTINGS_STORE_MOCK: Provider = {
provide: SettingsStore,
useClass: SettingsStoreMock,
};
@@ -25,7 +25,6 @@ ng_project(
"//:node_modules/@angular/core",
"//:node_modules/@angular/material",
"//:node_modules/@angular/platform-browser",
"//:node_modules/@types/chrome",
"//:node_modules/rxjs",
"//devtools/projects/ng-devtools",
"//devtools/projects/ng-devtools-backend",
@@ -7,6 +7,7 @@
*/
/// <reference types="chrome"/>
/// <reference types="firefox-webext-browser" />
import {Platform} from '@angular/cdk/platform';
import {inject} from '@angular/core';
@@ -16,21 +17,6 @@ import {DirectivePosition, ElementPosition, SignalNodePosition} from '../../../p
export class ChromeApplicationOperations extends ApplicationOperations {
platform = inject(Platform);
private runInInspectedWindow(script: string, target: Frame) {
if (this.platform.FIREFOX && target.id !== TOP_LEVEL_FRAME_ID) {
console.error(
'[Angular DevTools]: This browser does not support targeting a specific frame for eval by URL.',
);
return;
} else if (this.platform.FIREFOX) {
chrome.devtools.inspectedWindow.eval(script);
return;
}
const frameURL = target.url;
chrome.devtools.inspectedWindow.eval(script, {frameURL: frameURL?.toString?.()});
}
override viewSource(position: ElementPosition, target: Frame, directiveIndex?: number): void {
const viewSource = `inspect(inspectedApplication.findConstructorByPosition('${position}', ${directiveIndex}))`;
this.runInInspectedWindow(viewSource, target);
@@ -67,4 +53,38 @@ export class ChromeApplicationOperations extends ApplicationOperations {
const viewSource = `inspect(inspectedApplication.findConstructorByNameForRouter('${name}', '${type}'))`;
this.runInInspectedWindow(viewSource, target);
}
override setStorageItems(items: {[key: string]: unknown}): Promise<void> {
return this.storage.set(items);
}
override getStorageItems(items: string[]): Promise<{[key: string]: unknown}> {
return this.storage.get(items);
}
override removeStorageItems(items: string[]): Promise<void> {
return this.storage.remove(items);
}
private runInInspectedWindow(script: string, target: Frame) {
if (this.platform.FIREFOX && target.id !== TOP_LEVEL_FRAME_ID) {
console.error(
'[Angular DevTools]: This browser does not support targeting a specific frame for eval by URL.',
);
return;
} else if (this.platform.FIREFOX) {
chrome.devtools.inspectedWindow.eval(script);
return;
}
const frameURL = target.url;
chrome.devtools.inspectedWindow.eval(script, {frameURL: frameURL?.toString?.()});
}
private get storage(): typeof browser.storage.local {
if (!this.platform.FIREFOX) {
return chrome.storage.local;
}
return browser.storage.local;
}
}
@@ -38,7 +38,8 @@
},
"permissions": [
"scripting",
"activeTab"
"activeTab",
"storage"
],
"host_permissions": [
"<all_urls>"
@@ -25,6 +25,7 @@
},
"permissions": [
"activeTab",
"storage",
"http://*/*",
"https://*/*",
"file:///*"
+5 -1
View File
@@ -70,8 +70,12 @@ ng_project(
ng_project(
name = "demo_application_operations",
srcs = ["demo-application-operations.ts"],
srcs = [
"demo-application-operations.ts",
"local-storage.ts",
],
deps = [
"//:node_modules/@angular/core",
"//devtools/projects/ng-devtools",
"//devtools/projects/protocol",
],
@@ -6,30 +6,87 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {inject} from '@angular/core';
import {ApplicationOperations} from '../projects/ng-devtools';
import {DirectivePosition, ElementPosition, SignalNodePosition} from '../projects/protocol';
import {LOCAL_STORAGE} from './local-storage';
const STORAGE_KEY = 'ng-dt-storage-sim';
export class DemoApplicationOperations extends ApplicationOperations {
private readonly localStorage = inject(LOCAL_STORAGE);
override viewSource(position: ElementPosition): void {
console.warn('viewSource() is not implemented because the demo app runs in an Iframe');
throw new Error('Not implemented in demo app.');
}
override selectDomElement(position: ElementPosition): void {
console.warn('selectDomElement() is not implemented because the demo app runs in an Iframe');
throw new Error('Not implemented in demo app.');
}
override inspect(directivePosition: DirectivePosition, keyPath: string[]): void {
console.warn('inspect() is not implemented because the demo app runs in an Iframe');
return;
}
override inspectSignal(position: SignalNodePosition): void {
console.warn('inspectSignal() is not implemented because the demo app runs in an Iframe');
return;
}
override viewSourceFromRouter(name: string, type: string): void {
console.warn(
'viewSourceFromRouter() is not implemented because the demo app runs in an Iframe',
);
throw new Error('Not implemented in demo app.');
}
override async setStorageItems(items: {[key: string]: unknown}): Promise<void> {
const currItems = this.getLsItems();
this.setLsItems({...currItems, ...items});
}
override async getStorageItems(items: string[]): Promise<{[key: string]: unknown}> {
const currItems = this.getLsItems();
const redundant = Object.keys(currItems).filter((prop) => !items.includes(prop));
for (const item of redundant) {
delete currItems[item];
}
return currItems;
}
override async removeStorageItems(items: string[]): Promise<void> {
const currItems = this.getLsItems();
for (const item of items) {
delete currItems[item];
}
this.setLsItems(currItems);
}
private getLsItems(): {[key: string]: unknown} {
const storage = this.localStorage.getItem(STORAGE_KEY);
try {
return JSON.parse(storage ?? '{}');
} catch {
this.localStorage.removeItem(STORAGE_KEY);
console.error(
'Unable to parse the data from the simulated storage. Cleaning the item and returning a default object',
);
return {};
}
}
private setLsItems(items: object) {
try {
this.localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
} catch {
console.error('Unable to set item in the simulated storage.');
}
}
}
+14
View File
@@ -0,0 +1,14 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {InjectionToken} from '@angular/core';
export const LOCAL_STORAGE = new InjectionToken<typeof localStorage>('LOCAL_STORAGE', {
providedIn: 'root',
factory: () => localStorage,
});
+1
View File
@@ -88,6 +88,7 @@
"@types/chrome": "^0.1.0",
"@types/convert-source-map": "^2.0.0",
"@types/dom-navigation": "^1.0.5",
"@types/firefox-webext-browser": "^120.0.4",
"@types/hammerjs": "2.0.46",
"@types/jasmine": "^5.0.0",
"@types/jasminewd2": "^2.0.8",
+8
View File
@@ -141,6 +141,9 @@ importers:
'@types/dom-navigation':
specifier: ^1.0.5
version: 1.0.6
'@types/firefox-webext-browser':
specifier: ^120.0.4
version: 120.0.4
'@types/hammerjs':
specifier: 2.0.46
version: 2.0.46
@@ -3663,6 +3666,9 @@ packages:
'@types/filewriter@0.0.33':
resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==}
'@types/firefox-webext-browser@120.0.4':
resolution: {integrity: sha512-lBrpf08xhiZBigrtdQfUaqX1UauwZ+skbFiL8u2Tdra/rklkKadYmIzTwkNZSWtuZ7OKpFqbE2HHfDoFqvZf6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
@@ -14116,6 +14122,8 @@ snapshots:
'@types/filewriter@0.0.33': {}
'@types/firefox-webext-browser@120.0.4': {}
'@types/geojson@7946.0.16': {}
'@types/hammerjs@2.0.46': {}