blob: e879e264e529033999b594cb3367ee73a5ce8452 [file] [log] [blame]
// Copyright 2020 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import merge from 'lodash-es/merge';
import { computed, observable, reaction } from 'mobx';
import { createContextLink } from '../libs/context';
// Backward incompatible changes are not allowed.
export interface UserConfigs {
steps: {
showSucceededSteps: boolean;
showDebugLogs: boolean;
expandByDefault: boolean;
stepPinTime: {
// the key is the folded line.
// the value is the last accessed time.
[key: string]: number;
};
};
testResults: {
columnWidths: {
// the key is the property key.
// the value is the width in px.
[key: string]: number;
};
};
inputPropLineFoldTime: {
// the key is the folded line.
// the value is the last accessed time.
[key: string]: number;
};
outputPropLineFoldTime: {
// the key is the folded line.
// the value is the last accessed time.
[key: string]: number;
};
defaultBuildPageTabName: string;
}
export const DEFAULT_USER_CONFIGS = Object.freeze<UserConfigs>({
steps: Object.freeze({
showSucceededSteps: true,
showDebugLogs: false,
expandByDefault: false,
stepPinTime: Object.freeze({}),
}),
testResults: {
columnWidths: {
'v.test_suite': 350,
},
},
inputPropLineFoldTime: Object.freeze({}),
outputPropLineFoldTime: Object.freeze({}),
defaultBuildPageTabName: 'build-overview',
});
export class UserConfigsStore {
private static readonly KEY = 'user-configs-v1';
@observable readonly userConfigs = merge<{}, UserConfigs>({}, DEFAULT_USER_CONFIGS);
private disposers: Array<() => void> = [];
constructor(private readonly storage = window.localStorage) {
const storedConfigsStr = storage.getItem(UserConfigsStore.KEY) || '{}';
try {
merge(this.userConfigs, JSON.parse(storedConfigsStr));
} catch (e) {
console.error(e);
console.warn('encountered an error when parsing stored configs, deleting it');
storage.removeItem(UserConfigsStore.KEY);
}
this.disposers.push(
reaction(
() => JSON.stringify(this.userConfigs),
(configStr) => {
this.storage.setItem(UserConfigsStore.KEY, configStr);
},
// Add a tiny delay so updates happened in the same event cycle will
// only trigger one save event.
{ delay: 1 }
)
);
const fourWeeksAgo = Date.now() - 2419200000;
this.deleteStaleKeys(this.userConfigs.steps.stepPinTime, fourWeeksAgo);
this.deleteStaleKeys(this.userConfigs.inputPropLineFoldTime, fourWeeksAgo);
this.deleteStaleKeys(this.userConfigs.outputPropLineFoldTime, fourWeeksAgo);
}
/**
* Perform cleanup.
* Must be called before the object is GCed.
*/
dispose() {
this.disposers.reverse().forEach((disposer) => disposer());
this.disposers = [];
}
private deleteStaleKeys(records: { [key: string]: number }, beforeTimestamp: number) {
Object.entries(records)
.filter(([, timestamp]) => timestamp < beforeTimestamp)
.forEach(([key]) => delete records[key]);
}
/**
* Whether a step is pinned.
* The configuration is shared across all builds.
*/
stepIsPinned(stepName: string) {
const isPinned = computed(() => !!this.userConfigs.steps.stepPinTime[stepName]).get();
return isPinned;
}
/**
* Pin/unpin a step.
* The configuration is shared across all builds.
*
* When pinning a step, pin all its ancestors as well.
* When unpinning a step, unpin all its descendants as well.
*/
setStepPin(targetStepName: string, pinned: boolean) {
if (pinned) {
const now = Date.now();
let stepName = '';
for (const subStepName of targetStepName.split('|')) {
if (stepName) {
stepName += '|';
}
stepName += subStepName;
this.userConfigs.steps.stepPinTime[stepName] = now;
}
} else {
delete this.userConfigs.steps.stepPinTime[targetStepName];
const prefix = targetStepName + '|';
// TODO(weiweilin): use a trie instead of a dictionary if this is too slow.
Object.keys(this.userConfigs.steps.stepPinTime)
.filter((stepName) => stepName.startsWith(prefix))
.forEach((stepName) => {
delete this.userConfigs.steps.stepPinTime[stepName];
});
}
}
}
export const [provideConfigsStore, consumeConfigsStore] = createContextLink<UserConfigsStore>();