fix: apply theme on the fly in preferences, theme-store refactoring

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman committed 2020-07-23 18:26:16 +03:00
1 parent 6fd9b7cf76
commit 407c4fb4d0
15 files changed
+322 -315

No files matched your search

+10 -14
View File
@@ -1,3 +1,4 @@
import type { ThemeId } from "../renderer/theme.store";
import semver from "semver"
import { action, observable, reaction, toJS } from "mobx";
import { BaseStore } from "./base-store";
@@ -5,20 +6,8 @@ import migrations from "../migrations/user-store"
import { getAppVersion } from "./utils/app-version";
import { tracker } from "./tracker";
// todo: merge with config.store.ts + theme.store.ts
// fixme: detect new contexts from .kube/config since last open
export enum ThemeType {
LIGHT = "light",
DARK = "dark",
}
export interface Theme {
name: string;
type: ThemeType;
colors?: Record<string, string>;
}
export interface UserStoreModel {
lastSeenAppVersion: string;
seenContexts: string[];
@@ -27,13 +16,15 @@ export interface UserStoreModel {
export interface UserPreferences {
httpsProxy?: string;
colorTheme?: string | "dark" | "light";
colorTheme?: string;
allowUntrustedCAs?: boolean;
allowTelemetry?: boolean;
downloadMirror?: string | "default";
}
export class UserStore extends BaseStore<UserStoreModel> {
static readonly defaultTheme: ThemeId = "kontena-dark"
private constructor() {
super({
// configName: "lens-user-store", // todo: migrate from default filename
@@ -53,7 +44,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
@observable preferences: UserPreferences = {
allowTelemetry: true,
allowUntrustedCAs: false,
colorTheme: "dark",
colorTheme: UserStore.defaultTheme,
downloadMirror: "default",
httpsProxy: "",
};
@@ -62,6 +53,11 @@ export class UserStore extends BaseStore<UserStoreModel> {
return semver.gt(getAppVersion(), this.lastSeenAppVersion);
}
@action
resetTheme() {
this.preferences.colorTheme = UserStore.defaultTheme;
}
@action
saveLastSeenAppVersion() {
tracker.event("app", "whats-new-seen")
+2 -6
View File
@@ -2,14 +2,12 @@ import { app } from "electron"
import { CoreV1Api } from "@kubernetes/client-node"
import { LensApiRequest } from "../router"
import { LensApi } from "../lens-api"
import { userStore } from "../../common/user-store"
import { Cluster } from "../cluster"
export interface IConfigRoutePayload {
kubeVersion?: string;
clusterName?: string;
lensVersion?: string;
lensTheme?: string;
username?: string;
token?: string;
allowedNamespaces?: string[];
@@ -58,12 +56,11 @@ async function getAllowedNamespaces(cluster: Cluster) {
return namespaceList.body.items
.filter((ns, i) => nsAccessStatuses[i])
.map(ns => ns.metadata.name)
} catch(error) {
} catch (error) {
const ctx = cluster.getProxyKubeconfig().getContextObject(cluster.contextName)
if (ctx.namespace) {
return [ctx.namespace]
}
else {
} else {
return []
}
}
@@ -94,7 +91,6 @@ class ConfigRoute extends LensApi {
const data: IConfigRoutePayload = {
clusterName: cluster.contextName,
lensVersion: app.getVersion(),
lensTheme: userStore.preferences.colorTheme,
kubeVersion: cluster.version,
chartsEnabled: true,
isClusterAdmin: cluster.isAdmin,
@@ -14,6 +14,7 @@ import { Checkbox } from "../checkbox";
import { Notifications } from "../notifications";
import { Badge } from "../badge";
import { Spinner } from "../spinner";
import { themeStore } from "../../theme.store";
@observer
export class Preferences extends React.Component {
@@ -22,20 +23,22 @@ export class Preferences extends React.Component {
@observable helmRepos: HelmRepo[] = [];
@observable helmAddedRepos = observable.map<string, HelmRepo>();
@observable themeOptions: SelectOption<string>[] = [
{ value: "kontena-dark", label: <Trans>Dark</Trans> },
{ value: "kontena-light", label: <Trans>Light</Trans> },
]
@observable downloadMirrorOptions: SelectOption<string>[] = [
{ value: "default", label: "Default (Google)" },
{ value: "china", label: "China (Azure)" },
]
@computed get themeOptions(): SelectOption<string>[] {
return themeStore.themes.map(theme => ({
label: theme.name,
value: theme.id,
}))
}
@computed get helmOptions(): SelectOption<HelmRepo>[] {
return this.helmRepos.map(repo => ({
value: repo,
label: repo.name,
value: repo,
}))
}
@@ -80,7 +83,6 @@ export class Preferences extends React.Component {
}
onThemeChange = ({ value }: SelectOption<string>) => {
// themeStore.setTheme(value); // fixme: apply theme on the fly for current view
userStore.preferences.colorTheme = value;
}
@@ -129,7 +131,7 @@ export class Preferences extends React.Component {
<Select
options={this.themeOptions}
value={preferences.colorTheme}
onChange={this.onThemeChange}
onChange={({ value }: SelectOption) => preferences.colorTheme = value}
/>
<h2><Trans>Download Mirror</Trans></h2>
@@ -158,7 +160,7 @@ export class Preferences extends React.Component {
{this.helmLoading && <Spinner/>}
{Array.from(this.helmAddedRepos).map(([name, repo]) => {
return (
<Badge key={name} className="added-repo flex gaps align-center">
<Badge key={name} className="added-repo flex gaps align-center" title={repo.url}>
<span className="repo">{name}</span>
<Icon
material="remove_circle_outline"
@@ -1,5 +1,6 @@
import React, { useContext } from "react";
import { t } from "@lingui/macro";
import { observer } from "mobx-react";
import { IPodMetrics } from "../../api/endpoints";
import { BarChart, cpuOptions, memoryOptions } from "../chart";
import { isMetricsEmpty, normalizeMetrics } from "../../api/endpoints/metrics.api";
@@ -10,7 +11,7 @@ import { themeStore } from "../../theme.store";
type IContext = IResourceMetricsValue<any, { metrics: IPodMetrics }>;
export const ContainerCharts = () => {
export const ContainerCharts = observer(() => {
const { params: { metrics }, tabId } = useContext<IContext>(ResourceMetricsContext);
const { chartCapacityColor } = themeStore.activeTheme.colors;
@@ -100,4 +101,4 @@ export const ContainerCharts = () => {
data={{ datasets: datasets[tabId] }}
/>
);
}
})
+123 -125
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useRef } from "react";
import React from "react";
import merge from "lodash/merge";
import moment from "moment";
import Color from "color";
import { observer } from "mobx-react";
import { ChartData, ChartOptions, ChartPoint, Scriptable } from "chart.js";
import { Chart, ChartKind, ChartProps } from "./chart";
import { bytesToUnits, cssNames } from "../../utils";
@@ -19,139 +20,136 @@ const defaultProps: Partial<Props> = {
plugins: [ZebraStripes]
};
BarChart.defaultProps = defaultProps;
@observer
export class BarChart extends React.Component<Props> {
static defaultProps = defaultProps as object;
export function BarChart(props: Props) {
const { name, data, className, timeLabelStep, plugins, options: customOptions, ...settings } = props;
const { textColorPrimary, borderFaintColor, chartStripesColor } = themeStore.activeTheme.colors;
render() {
const { name, data, className, timeLabelStep, plugins, options: customOptions, ...settings } = this.props;
const { textColorPrimary, borderFaintColor, chartStripesColor } = themeStore.activeTheme.colors;
const savedName = useRef<string>();
const getBarColor: Scriptable<string> = ({ dataset }) => {
const color = dataset.borderColor;
return Color(color).alpha(0.2).string();
}
useEffect(() => {
savedName.current = props.name;
});
const getBarColor: Scriptable<string> = ({ dataset }) => {
const color = dataset.borderColor;
return Color(color).alpha(0.2).string();
}
// Remove empty sets and insert default data
const chartData: ChartData = {
...data,
datasets: data.datasets
.filter(set => set.data.length)
.map(item => {
return {
type: ChartKind.BAR,
borderWidth: { top: 3 },
barPercentage: 1,
categoryPercentage: 1,
...item
}
})
};
const formatTimeLabels = (timestamp: string, index: number) => {
const label = moment(parseInt(timestamp)).format("HH:mm");
const offset = " ";
if (index == 0) return offset + label;
if (index == 60) return label + offset;
return index % timeLabelStep == 0 ? label : "";
};
const barOptions: ChartOptions = {
maintainAspectRatio: false,
responsive: true,
scales: {
xAxes: [{
type: "time",
offset: true,
gridLines: {
display: false,
},
stacked: true,
ticks: {
callback: formatTimeLabels,
autoSkip: false,
source: "data",
backdropColor: "white",
fontColor: textColorPrimary,
fontSize: 11,
maxRotation: 0,
minRotation: 0
},
bounds: "data",
time: {
unit: "minute",
displayFormats: {
minute: "x"
},
parser: timestamp => moment.unix(parseInt(timestamp))
}
}],
yAxes: [{
position: "right",
gridLines: {
color: borderFaintColor,
drawBorder: false,
tickMarkLength: 0,
zeroLineWidth: 0
},
ticks: {
maxTicksLimit: 6,
fontColor: textColorPrimary,
fontSize: 11,
padding: 8,
min: 0
}
}]
},
tooltips: {
mode: "index",
position: "cursor",
callbacks: {
title: tooltipItems => {
const now = new Date().getTime()
if (new Date(tooltipItems[0].xLabel).getTime() > now) return "";
return `${tooltipItems[0].xLabel}`
},
labelColor: ({ datasetIndex }) => {
// Remove empty sets and insert default data
const chartData: ChartData = {
...data,
datasets: data.datasets
.filter(set => set.data.length)
.map(item => {
return {
borderColor: "darkgray",
backgroundColor: chartData.datasets[datasetIndex].borderColor as string
type: ChartKind.BAR,
borderWidth: { top: 3 },
barPercentage: 1,
categoryPercentage: 1,
...item
}
})
};
const formatTimeLabels = (timestamp: string, index: number) => {
const label = moment(parseInt(timestamp)).format("HH:mm");
const offset = " ";
if (index == 0) return offset + label;
if (index == 60) return label + offset;
return index % timeLabelStep == 0 ? label : "";
};
const barOptions: ChartOptions = {
maintainAspectRatio: false,
responsive: true,
scales: {
xAxes: [{
type: "time",
offset: true,
gridLines: {
display: false,
},
stacked: true,
ticks: {
callback: formatTimeLabels,
autoSkip: false,
source: "data",
backdropColor: "white",
fontColor: textColorPrimary,
fontSize: 11,
maxRotation: 0,
minRotation: 0
},
bounds: "data",
time: {
unit: "minute",
displayFormats: {
minute: "x"
},
parser: timestamp => moment.unix(parseInt(timestamp))
}
}],
yAxes: [{
position: "right",
gridLines: {
color: borderFaintColor,
drawBorder: false,
tickMarkLength: 0,
zeroLineWidth: 0
},
ticks: {
maxTicksLimit: 6,
fontColor: textColorPrimary,
fontSize: 11,
padding: 8,
min: 0
}
}]
},
tooltips: {
mode: "index",
position: "cursor",
callbacks: {
title: tooltipItems => {
const now = new Date().getTime()
if (new Date(tooltipItems[0].xLabel).getTime() > now) return "";
return `${tooltipItems[0].xLabel}`
},
labelColor: ({ datasetIndex }) => {
return {
borderColor: "darkgray",
backgroundColor: chartData.datasets[datasetIndex].borderColor as string
}
}
}
},
animation: {
duration: 0
},
elements: {
rectangle: {
backgroundColor: getBarColor.bind(null)
}
},
plugins: {
ZebraStripes: {
stripeColor: chartStripesColor
}
}
},
animation: {
duration: 0
},
elements: {
rectangle: {
backgroundColor: getBarColor.bind(null)
}
},
plugins: {
ZebraStripes: {
stripeColor: chartStripesColor
}
};
const options = merge(barOptions, customOptions);
if (chartData.datasets.length == 0) {
return <NoMetrics/>
}
};
const options = merge(barOptions, customOptions);
if (chartData.datasets.length == 0) {
return <NoMetrics/>
return (
<Chart
className={cssNames("BarChart flex box grow column", className)}
type={ChartKind.BAR}
data={chartData}
options={options}
plugins={plugins}
{...settings}
/>
)
}
return (
<Chart
className={cssNames("BarChart flex box grow column", className)}
type={ChartKind.BAR}
data={chartData}
options={options}
plugins={plugins}
{...settings}
/>
)
}
// Default options for all charts containing memory units (network, disk, memory, etc)
@@ -1,5 +1,6 @@
import "./pie-chart.scss";
import React from "react";
import { observer } from "mobx-react";
import ChartJS, { ChartOptions } from "chart.js";
import { Chart, ChartProps } from "./chart";
import { cssNames } from "../../utils";
@@ -8,6 +9,7 @@ import { themeStore } from "../../theme.store";
interface Props extends ChartProps {
}
@observer
export class PieChart extends React.Component<Props> {
render() {
const { data, className, options, ...chartProps } = this.props
@@ -6,6 +6,7 @@ import { BottomBar } from "./bottom-bar";
import { cssNames, IClassName } from "../../utils";
import { Terminal } from "../dock/terminal";
import { i18nStore } from "../../i18n";
import { themeStore } from "../../theme.store";
interface Props {
className?: IClassName;
@@ -14,8 +15,11 @@ interface Props {
export class ClusterManager extends React.Component<Props> {
static async init() {
await i18nStore.init()
await Terminal.preloadFonts()
await Promise.all([
i18nStore.init(),
themeStore.init(),
Terminal.preloadFonts(),
])
}
render() {
+10 -11
View File
@@ -1,5 +1,5 @@
import debounce from "lodash/debounce";
import { autorun } from "mobx";
import { reaction, toJS } from "mobx";
import { Terminal as XTerm } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { dockStore, TabId } from "./dock.store";
@@ -20,7 +20,7 @@ export class Terminal {
Terminal.spawningPool = pool;
}
static async preloadFonts(){
static async preloadFonts() {
const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires
const fontFace = new FontFace("RobotoMono", `url(${fontPath})`);
await fontFace.load();
@@ -33,7 +33,7 @@ export class Terminal {
public disposers: Function[] = [];
@autobind()
protected setTheme(colors = themeStore.activeTheme.colors) {
protected setTheme(colors: Record<string, string>) {
// Replacing keys stored in styles to format accepted by terminal
// E.g. terminalBrightBlack -> brightBlack
const colorPrefix = "terminal"
@@ -94,19 +94,18 @@ export class Terminal {
this.xterm.attachCustomKeyEventHandler(this.keyHandler);
// bind events
const onResizeDisposer = dockStore.onResize(this.onResize);
const onData = this.xterm.onData(this.onData);
const onThemeChangeDisposer = autorun(() => this.setTheme(themeStore.activeTheme.colors));
const onDataHandler = this.xterm.onData(this.onData);
this.viewport.addEventListener("scroll", this.onScroll);
this.api.onReady.addListener(this.onClear, { once: true }); // clear status logs (connecting..)
this.api.onData.addListener(this.onApiData);
window.addEventListener("resize", this.onResize);
// add clean-up handlers to be called on destroy
this.disposers.push(
onResizeDisposer,
onThemeChangeDisposer,
() => onData.dispose(),
reaction(() => toJS(themeStore.activeTheme.colors), this.setTheme, {
fireImmediately: true
}),
dockStore.onResize(this.onResize),
() => onDataHandler.dispose(),
() => this.fitAddon.dispose(),
() => this.api.removeAllListeners(),
() => window.removeEventListener("resize", this.onResize),
@@ -171,7 +170,7 @@ export class Terminal {
// Handle custom hotkey bindings
if (ctrlKey) {
switch (code) {
// Ctrl+C: prevent terminal exit on windows / linux (?)
// Ctrl+C: prevent terminal exit on windows / linux (?)
case "KeyC":
if (this.xterm.hasSelection()) return false;
break;
@@ -8,7 +8,7 @@
grid-template-columns: 1fr 40%;
> * {
@include hidden-scrollbar;
@include custom-scrollbar;
--flex-gap: #{$spacing};
padding: $spacing;
}
+72 -65
View File
@@ -1,91 +1,98 @@
import { action, autorun, computed, observable, reaction } from "mobx";
import { autobind, createStorage } from "./utils";
import { Notifications } from "./components/notifications";
import { Theme, ThemeType } from "../common/user-store";
import { computed, observable, reaction } from "mobx";
import { autobind } from "./utils";
import { userStore } from "../common/user-store";
import logger from "../main/logger";
export type ThemeId = string;
export enum ThemeType {
DARK = "dark",
LIGHT = "light",
}
export interface Theme {
id: ThemeId; // filename without .json-extension
type: ThemeType;
name?: string;
colors?: Record<string, string>;
description?: string;
author?: string;
}
@autobind()
export class ThemeStore {
protected style = document.createElement("style");
protected styles: HTMLStyleElement;
readonly defaultTheme: Theme = {
name: "kontena-dark",
type: ThemeType.DARK,
colors: {},
};
// bundled themes from `themes/${themeId}.json`
@observable themes: Theme[] = [
{ id: "kontena-dark", type: ThemeType.DARK },
{ id: "kontena-light", type: ThemeType.LIGHT },
];
@observable activeThemeId = this.defaultTheme.name; // theme's filename without extension
@observable themes = observable.map<string, Theme>([], { deep: false });
@computed get activeThemeId() {
return userStore.preferences.colorTheme;
}
@computed get activeTheme() {
return this.themes.get(this.activeThemeId) || this.defaultTheme;
@computed get activeTheme(): Theme {
const activeTheme = this.themes.find(theme => theme.id === this.activeThemeId) || this.themes[0];
return {
colors: {},
...activeTheme,
}
}
constructor() {
const storage = createStorage("theme", this.activeThemeId);
this.activeThemeId = storage.get();
// init
this.style.id = "lens-theme"
document.head.prepend(this.style);
this.setTheme(this.activeThemeId);
// save active theme-id in local storage
reaction(() => this.activeThemeId, themeId => storage.set(themeId), {
fireImmediately: true
});
// auto-apply colors to dom from active theme
reaction(() => this.activeTheme, this.onChange, {
// auto-apply active theme
reaction(() => this.activeThemeId, async themeId => {
try {
await this.loadTheme(themeId);
this.applyTheme();
} catch (err) {
userStore.resetTheme();
}
}, {
fireImmediately: true,
delay: 150,
});
// apply theme from configuration
import("./config.store").then(({ configStore }) => {
autorun(() => {
const themeId = configStore.config.lensTheme;
if (themeId && themeId !== this.activeThemeId) {
this.setTheme(themeId);
}
});
})
}
protected onChange = (theme: Theme) => {
let cssText = "\n"
Object.entries(theme.colors).forEach(([propName, color]) => {
cssText += `--${propName}: ${color} !important;\n`
});
this.style.textContent = `:root {${cssText}} `;
async init() {
// preload all themes
await Promise.all(
this.themes.map(theme => this.loadTheme(theme.id))
);
}
async load(themeId: string, { showErrorNotification = true } = {}): Promise<Theme> {
if (this.themes.has(themeId)) {
return this.themes.get(themeId);
}
getThemeById(themeId: ThemeId): Theme {
return this.themes.find(theme => theme.id === themeId)
}
protected async loadTheme(themeId: ThemeId): Promise<Theme> {
try {
const theme: Theme = require( // eslint-disable-line @typescript-eslint/no-var-requires
/* webpackMode: "lazy", webpackChunkName: "theme/[request]" */
// todo: figure out why await import() doesn't work
const theme = require( // eslint-disable-line @typescript-eslint/no-var-requires
/* webpackChunkName: "themes/[name]" */
`./themes/${themeId}.json`
);
this.themes.set(themeId, theme);
return theme;
const existingTheme = this.getThemeById(themeId);
if (existingTheme) {
Object.assign(existingTheme, theme); // merge
}
return existingTheme;
} catch (err) {
if (showErrorNotification) Notifications.error(err.toString());
throw err;
logger.error(`Can't load theme "${themeId}": ${err}`);
}
}
@action
async setTheme(themeId = this.defaultTheme.name) {
try {
await this.load(themeId);
this.activeThemeId = themeId;
} catch (err) {
if (themeId !== this.defaultTheme.name) {
this.setTheme(); // fallback to default theme
}
protected applyTheme(theme = this.activeTheme) {
if (!this.styles) {
this.styles = document.createElement("style");
this.styles.id = "lens-theme"
document.head.prepend(this.styles);
}
const cssVars = Object.entries(theme.colors).map(([cssName, color]) => {
return `--${cssName}: ${color} !important;`
});
this.styles.textContent = `:root {\n${cssVars.join("\n")}}`;
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "Lens Dark Theme",
"name": "Dark (Lens)",
"type": "dark",
"description": "Original Lens dark theme",
"author": "Lakend Labs",
"colors": {
"blue": "#3d90ce",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "Lens Light Theme",
"name": "Light (Lens)",
"type": "light",
"description": "Original Lens light theme",
"author": "Lakend Labs",
"colors": {
"blue": "#3d90ce",