Files
Termix/src/types/ui-preferences.ts
T

476 lines
15 KiB
TypeScript

/**
* App-wide UI complexity preferences. Shared by the frontend UI preferences
* context and the backend preferences endpoint (no framework imports, mirrors
* ./host-sidebar-preferences.ts's dependency-free convention -- the backend's
* NodeNext build cannot resolve the "@/" frontend path alias).
*
* The model stores the user's *intent* -- a preset plus the individual knobs
* they have deliberately changed -- not a second copy of values other stores
* already own. Areas whose knobs already live somewhere else (host sidebar
* blob, user_preferences.hiddenRailTabs, a handful of localStorage keys) are
* seeded from the preset when it changes; reads keep going to the existing
* store. See applyPresetSideEffects on the frontend.
*
* "balanced" is exactly today's behavior. Every value in PRESETS.balanced is
* transcribed from the defaults that were already in the code, so existing
* users who land on it see no change at all.
*/
/** 2: the docker and host metrics areas moved to their plugins. */
export const UI_PREFERENCES_VERSION = 3;
/** Bump when onboarding gains steps existing users should be shown again. */
export const UI_ONBOARDING_VERSION = 2;
export type UiPreset = "simple" | "balanced" | "advanced" | "custom";
export type UiAreaKey =
| "chrome"
| "hostList"
| "credentialList"
| "rail"
| "dashboard"
| "hostEditor";
export type UiDensity = "comfortable" | "compact";
export type UiTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
export type UiRowActions = "essential" | "full";
export type UiEmptyStateVerbosity = "minimal" | "guided";
export type UiHostEditorMode = "simple" | "full";
export interface UiChromePreferences {
showBreadcrumbs: boolean;
showStatusBar: boolean;
emptyStateVerbosity: UiEmptyStateVerbosity;
}
export interface UiHostListPreferences {
density: UiDensity;
showTags: boolean;
showResourceBars: boolean;
showStatusStripes: boolean;
trayTrigger: UiTrayTrigger;
rowActions: UiRowActions;
}
export interface UiCredentialListPreferences {
density: UiDensity;
showTags: boolean;
}
export interface UiRailPreferences {
hiddenTabs: string[];
/**
* Also hide every plugin rail item that does not set simplePreset. Plugin
* items are only known at runtime, so the preset cannot list them.
*/
hidePluginItems?: boolean;
}
export interface UiDashboardPreferences {
enabledCards: string[];
}
export interface UiHostEditorPreferences {
mode: UiHostEditorMode;
}
export interface UiAreaPreferences {
chrome: UiChromePreferences;
hostList: UiHostListPreferences;
credentialList: UiCredentialListPreferences;
rail: UiRailPreferences;
dashboard: UiDashboardPreferences;
hostEditor: UiHostEditorPreferences;
}
/** A plugin's own area, keyed "plugin:<id>", holding what it declared in contributes.uiPresets. */
export type UiPluginAreaKey = `plugin:${string}`;
export type UiOverrides = {
[A in UiAreaKey]?: Partial<UiAreaPreferences[A]>;
} & { [key: UiPluginAreaKey]: Record<string, unknown> };
export interface UiOnboardingState {
/** 0 means "never completed". Compared against UI_ONBOARDING_VERSION. */
completedVersion: number;
completedAt: string | null;
skipped: boolean;
}
export interface UiPreferences {
version: number;
preset: UiPreset;
overrides: UiOverrides;
onboarding: UiOnboardingState;
}
const PRESET_VALUES: UiPreset[] = ["simple", "balanced", "advanced", "custom"];
/**
* Core rail views Simple keeps. Cutting all the way down to hosts+credentials
* makes the app feel broken, so connections stays: it is where
* troubleshooting starts. Plugin items opt in with simplePreset.
*/
const SIMPLE_RAIL_VISIBLE = ["hosts", "credentials", "connections"];
/** Core's hideable rail views, mirroring RAIL_ITEMS in sidebar/rail-items.ts. */
const CORE_HIDEABLE_RAIL_VIEWS = [
"hosts",
"credentials",
"connections",
"quick-connect",
];
const SIMPLE_HIDDEN_RAIL_TABS = CORE_HIDEABLE_RAIL_VIEWS.filter(
(view) => !SIMPLE_RAIL_VISIBLE.includes(view),
);
/** Dashboard card ids, mirroring DASHBOARD_CARDS in ui/lib/theme.ts. */
const BALANCED_DASHBOARD_CARDS = [
"stats_bar",
"counters_bar",
"quick_actions",
"host_status",
"recent_activity",
];
// Only core's own cards: a plugin's cards are added from the Add card tray.
const ADVANCED_DASHBOARD_CARDS = [...BALANCED_DASHBOARD_CARDS];
export const PRESETS: Record<Exclude<UiPreset, "custom">, UiAreaPreferences> = {
simple: {
chrome: {
showBreadcrumbs: false,
showStatusBar: false,
emptyStateVerbosity: "guided",
},
hostList: {
density: "comfortable",
showTags: false,
showResourceBars: false,
showStatusStripes: false,
// "always" permanently renders the management row and resource bars,
// which is the wall of buttons the issue calls intimidating.
trayTrigger: "actionsOnly",
rowActions: "essential",
},
credentialList: { density: "comfortable", showTags: false },
rail: { hiddenTabs: SIMPLE_HIDDEN_RAIL_TABS, hidePluginItems: true },
dashboard: {
enabledCards: [
"stats_bar",
"counters_bar",
"quick_actions",
"host_status",
],
},
hostEditor: { mode: "simple" },
},
balanced: {
chrome: {
showBreadcrumbs: true,
showStatusBar: true,
emptyStateVerbosity: "minimal",
},
hostList: {
density: "comfortable",
showTags: true,
showResourceBars: true,
showStatusStripes: true,
trayTrigger: "always",
rowActions: "full",
},
credentialList: { density: "comfortable", showTags: true },
rail: { hiddenTabs: [] },
dashboard: { enabledCards: BALANCED_DASHBOARD_CARDS },
// 3 is defaultLayoutFromWidgets's own default, i.e. today's behavior.
hostEditor: { mode: "full" },
},
advanced: {
chrome: {
showBreadcrumbs: true,
showStatusBar: true,
emptyStateVerbosity: "minimal",
},
hostList: {
density: "compact",
showTags: true,
showResourceBars: true,
showStatusStripes: true,
trayTrigger: "always",
rowActions: "full",
},
credentialList: { density: "compact", showTags: true },
rail: { hiddenTabs: [] },
dashboard: { enabledCards: ADVANCED_DASHBOARD_CARDS },
hostEditor: { mode: "full" },
},
};
type FieldSpec =
| { kind: "enum"; values: readonly string[] }
| { kind: "bool" }
| { kind: "int"; min: number; max: number }
| { kind: "stringArray" };
/**
* Field descriptors for every area knob. Unlike the flat sanitizers on the
* sidebar preference blobs, overrides are a sparse two-level map, so one table
* drives both levels instead of a ternary per field.
*/
const AREA_SPECS: {
[A in UiAreaKey]: Record<keyof UiAreaPreferences[A] & string, FieldSpec>;
} = {
chrome: {
showBreadcrumbs: { kind: "bool" },
showStatusBar: { kind: "bool" },
emptyStateVerbosity: { kind: "enum", values: ["minimal", "guided"] },
},
hostList: {
density: { kind: "enum", values: ["comfortable", "compact"] },
showTags: { kind: "bool" },
showResourceBars: { kind: "bool" },
showStatusStripes: { kind: "bool" },
trayTrigger: {
kind: "enum",
values: ["always", "hover", "click", "actionsOnly"],
},
rowActions: { kind: "enum", values: ["essential", "full"] },
},
credentialList: {
density: { kind: "enum", values: ["comfortable", "compact"] },
showTags: { kind: "bool" },
},
rail: {
hiddenTabs: { kind: "stringArray" },
hidePluginItems: { kind: "bool" },
},
dashboard: {
enabledCards: { kind: "stringArray" },
},
hostEditor: {
mode: { kind: "enum", values: ["simple", "full"] },
},
};
export const UI_AREA_KEYS = Object.keys(AREA_SPECS) as UiAreaKey[];
/** Returns undefined when the value fails its spec, so callers can drop it. */
function coerce(spec: FieldSpec, value: unknown): unknown | undefined {
switch (spec.kind) {
case "bool":
return typeof value === "boolean" ? value : undefined;
case "enum":
return typeof value === "string" && spec.values.includes(value)
? value
: undefined;
case "int": {
if (typeof value !== "number" || !Number.isFinite(value))
return undefined;
const rounded = Math.round(value);
if (rounded < spec.min || rounded > spec.max) return undefined;
return rounded;
}
case "stringArray":
return Array.isArray(value)
? value.filter((v): v is string => typeof v === "string")
: undefined;
}
}
/**
* Drops unknown areas, unknown keys and invalid values, then prunes areas that
* ended up empty. The pruning matters: it keeps
* Object.keys(overrides).length > 0 an honest "has customizations" check for
* the settings UI rather than something that accumulates {hostList:{}} noise.
*/
const PLUGIN_AREA_PATTERN = /^plugin:[a-z][a-z0-9-]{0,63}$/;
const PLUGIN_AREA_MAX_KEYS = 32;
/**
* A plugin area's values are only checked for shape: core does not know the
* plugin's fields, and the plugin reads them against its own presets.
*/
function sanitizePluginArea(input: unknown): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (!input || typeof input !== "object") return out;
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (Object.keys(out).length >= PLUGIN_AREA_MAX_KEYS) break;
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(key)) continue;
if (
typeof value === "boolean" ||
(typeof value === "number" && Number.isFinite(value)) ||
(typeof value === "string" && value.length <= 200)
) {
out[key] = value;
} else if (
Array.isArray(value) &&
value.length <= 100 &&
value.every((item) => typeof item === "string" && item.length <= 200)
) {
out[key] = [...value];
}
}
return out;
}
/**
* Areas that moved into a plugin: before `version` they were stored under
* core's name. Version 1 had docker and host metrics, version 2 the terminal
* and the file manager.
*/
const MOVED_PLUGIN_AREAS: Array<{
before: number;
legacy: string;
area: UiPluginAreaKey;
}> = [
{ before: 2, legacy: "docker", area: "plugin:docker" }, // plugin-id-ok: 2.8 key
{ before: 2, legacy: "hostMetrics", area: "plugin:host-metrics" },
{ before: 3, legacy: "terminal", area: "plugin:ssh-terminal" },
{ before: 3, legacy: "fileManager", area: "plugin:file-manager" },
];
export function sanitizeUiOverrides(
input: unknown,
version = UI_PREFERENCES_VERSION,
): UiOverrides {
const out: Record<string, Record<string, unknown>> = {};
if (!input || typeof input !== "object") return out as UiOverrides;
const moves = MOVED_PLUGIN_AREAS.filter((move) => version < move.before);
if (moves.length > 0) {
const upgraded = { ...(input as Record<string, unknown>) };
for (const { legacy, area } of moves) {
if (legacy in upgraded) {
upgraded[area] = upgraded[legacy];
delete upgraded[legacy];
}
}
input = upgraded;
}
const specsByArea = AREA_SPECS as unknown as Record<
string,
Record<string, FieldSpec>
>;
for (const [area, areaValue] of Object.entries(
input as Record<string, unknown>,
)) {
if (PLUGIN_AREA_PATTERN.test(area)) {
const bucket = sanitizePluginArea(areaValue);
if (Object.keys(bucket).length > 0) out[area] = bucket;
continue;
}
const specs = specsByArea[area];
if (!specs || !areaValue || typeof areaValue !== "object") continue;
const bucket: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(
areaValue as Record<string, unknown>,
)) {
const spec = specs[key];
if (!spec) continue;
const value = coerce(spec, raw);
if (value !== undefined) bucket[key] = value;
}
if (Object.keys(bucket).length > 0) out[area] = bucket;
}
return out as UiOverrides;
}
function sanitizeOnboarding(input: unknown): UiOnboardingState {
const defaults: UiOnboardingState = {
completedVersion: 0,
completedAt: null,
skipped: false,
};
if (!input || typeof input !== "object") return defaults;
const obj = input as Record<string, unknown>;
return {
completedVersion:
typeof obj.completedVersion === "number" &&
Number.isFinite(obj.completedVersion) &&
obj.completedVersion >= 0
? Math.round(obj.completedVersion)
: defaults.completedVersion,
completedAt:
typeof obj.completedAt === "string"
? obj.completedAt
: defaults.completedAt,
skipped: typeof obj.skipped === "boolean" ? obj.skipped : defaults.skipped,
};
}
export function defaultUiPreferences(): UiPreferences {
return {
version: UI_PREFERENCES_VERSION,
preset: "balanced",
overrides: {},
onboarding: { completedVersion: 0, completedAt: null, skipped: false },
};
}
export function sanitizeUiPreferences(input: unknown): UiPreferences {
const defaults = defaultUiPreferences();
if (!input || typeof input !== "object") return defaults;
const obj = input as Record<string, unknown>;
return {
version: UI_PREFERENCES_VERSION,
preset: PRESET_VALUES.includes(obj.preset as UiPreset)
? (obj.preset as UiPreset)
: defaults.preset,
overrides: sanitizeUiOverrides(
obj.overrides,
typeof obj.version === "number" ? obj.version : 1,
),
onboarding: sanitizeOnboarding(obj.onboarding),
};
}
/**
* Effective values for one area: preset defaults with the user's overrides
* layered on top. "custom" only labels a diverged state, so it re-bases on
* balanced rather than carrying a fourth value table.
*/
export function resolveArea<A extends UiAreaKey>(
preferences: UiPreferences,
area: A,
): UiAreaPreferences[A] {
const base =
PRESETS[preferences.preset === "custom" ? "balanced" : preferences.preset][
area
];
const override = preferences.overrides[area];
return override ? { ...base, ...override } : base;
}
/** The presets a plugin declares in contributes.uiPresets. */
export type UiPluginPresets = Record<
Exclude<UiPreset, "custom">,
Record<string, unknown>
>;
/** A plugin area's values: its preset for the user's level, then overrides. */
export function resolvePluginArea(
preferences: UiPreferences,
pluginId: string,
presets: UiPluginPresets | undefined,
): Record<string, unknown> {
const level =
preferences.preset === "custom" ? "balanced" : preferences.preset;
const base = presets?.[level] ?? {};
const override = (
preferences.overrides as Record<string, Record<string, unknown>>
)[`plugin:${pluginId}`];
return override ? { ...base, ...override } : { ...base };
}
export function hasUiOverrides(preferences: UiPreferences): boolean {
return Object.keys(preferences.overrides).length > 0;
}