mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
296 lines
8.6 KiB
TypeScript
296 lines
8.6 KiB
TypeScript
/**
|
|
* Host sidebar preferences model. Shared by the frontend sidebar and the
|
|
* backend preferences endpoint (no framework imports, mirrors
|
|
* ./host-metrics.ts's dependency-free convention). Replaces the ~10
|
|
* independent localStorage keys/custom events the sidebar used to manage
|
|
* individually.
|
|
*
|
|
* SortKey and StatusColorScheme are duplicated here (rather than imported
|
|
* from src/ui/sidebar/host-sort.ts / src/ui/hooks/use-status-color-scheme.ts)
|
|
* because those files use the "@/" frontend path alias, which the backend's
|
|
* NodeNext build cannot resolve. Keep the values below in sync with those
|
|
* two files.
|
|
*/
|
|
|
|
export const HOST_SIDEBAR_PREFS_VERSION = 1;
|
|
|
|
export type SortKey =
|
|
| "default"
|
|
| "name-asc"
|
|
| "name-desc"
|
|
| "ip-asc"
|
|
| "ip-desc"
|
|
| "status-online"
|
|
| "status-offline"
|
|
| "manual";
|
|
|
|
export type StatusColorScheme = "accent" | "status";
|
|
|
|
export type HostGroupKey = "folder" | "tag" | "status" | "protocol" | "auth";
|
|
|
|
export type HostDensity = "comfortable" | "compact";
|
|
|
|
export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
|
|
|
|
/** What clicking a host does when it already has an open tab. */
|
|
export type HostClickBehavior =
|
|
"newTab" | "focusExisting" | "focusExistingDoubleClickNew";
|
|
|
|
export interface HostSidebarFilterState {
|
|
status: ("online" | "offline" | "pinned")[];
|
|
/** SSH auth type ids; plugins add their own. */
|
|
authType: string[];
|
|
/** "ssh" or a plugin protocol id. */
|
|
protocol: string[];
|
|
/** Plugin ids whose host switch must be on. */
|
|
features: string[];
|
|
tags: string[];
|
|
}
|
|
|
|
/** Which parts of a host row are shown. */
|
|
export interface HostRowFields {
|
|
showAddress: boolean;
|
|
showUsername: boolean;
|
|
showPort: boolean;
|
|
showPinIcon: boolean;
|
|
showSharedBadge: boolean;
|
|
/** Badges plugins add next to the name. */
|
|
showBadges: boolean;
|
|
}
|
|
|
|
export const HOST_ROW_FIELD_KEYS: (keyof HostRowFields)[] = [
|
|
"showAddress",
|
|
"showUsername",
|
|
"showPort",
|
|
"showPinIcon",
|
|
"showSharedBadge",
|
|
"showBadges",
|
|
];
|
|
|
|
export function defaultHostRowFields(): HostRowFields {
|
|
return {
|
|
showAddress: true,
|
|
showUsername: true,
|
|
showPort: false,
|
|
showPinIcon: true,
|
|
showSharedBadge: true,
|
|
showBadges: true,
|
|
};
|
|
}
|
|
|
|
export interface HostSidebarDisplayPreferences extends HostRowFields {
|
|
density: HostDensity;
|
|
showTags: boolean;
|
|
trayTrigger: HostTrayTrigger;
|
|
statusColorScheme: StatusColorScheme;
|
|
/** When true, a host row needs a double click to launch its session. */
|
|
openOnDoubleClick: boolean;
|
|
/** When false, nested folders hide the parent-path breadcrumb before their name. */
|
|
showFolderPaths: boolean;
|
|
hostClickBehavior: HostClickBehavior;
|
|
/** When true, the sidebar closes after a host is opened from it. */
|
|
closeOnOpen: boolean;
|
|
/**
|
|
* Host action id to whether it shows in the row's connect bar. An action
|
|
* missing here follows its own default. Every action stays in the menu.
|
|
*/
|
|
barActions: Record<string, boolean>;
|
|
}
|
|
|
|
export interface HostSidebarPreferences {
|
|
version: number;
|
|
sort: { key: SortKey; pinnedFirst: boolean };
|
|
groupKey: HostGroupKey;
|
|
filters: HostSidebarFilterState;
|
|
openFolders: string[];
|
|
display: HostSidebarDisplayPreferences;
|
|
}
|
|
|
|
const GROUP_KEYS: HostGroupKey[] = [
|
|
"folder",
|
|
"tag",
|
|
"status",
|
|
"protocol",
|
|
"auth",
|
|
];
|
|
const SORT_KEYS: SortKey[] = [
|
|
"default",
|
|
"name-asc",
|
|
"name-desc",
|
|
"ip-asc",
|
|
"ip-desc",
|
|
"status-online",
|
|
"status-offline",
|
|
"manual",
|
|
];
|
|
const DENSITIES: HostDensity[] = ["comfortable", "compact"];
|
|
const TRAY_TRIGGERS: HostTrayTrigger[] = [
|
|
"always",
|
|
"hover",
|
|
"click",
|
|
"actionsOnly",
|
|
];
|
|
const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
|
|
"newTab",
|
|
"focusExisting",
|
|
"focusExistingDoubleClickNew",
|
|
];
|
|
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
|
|
const FILTER_STATUS: HostSidebarFilterState["status"] = [
|
|
"online",
|
|
"offline",
|
|
"pinned",
|
|
];
|
|
/** Same shape as a manifest's auth type, protocol and plugin ids. */
|
|
const AUTH_TYPE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
|
|
export function defaultHostSidebarPreferences(): HostSidebarPreferences {
|
|
return {
|
|
version: HOST_SIDEBAR_PREFS_VERSION,
|
|
sort: { key: "default", pinnedFirst: false },
|
|
groupKey: "folder",
|
|
filters: {
|
|
status: [],
|
|
authType: [],
|
|
protocol: [],
|
|
features: [],
|
|
tags: [],
|
|
},
|
|
openFolders: [],
|
|
display: {
|
|
density: "comfortable",
|
|
showTags: true,
|
|
trayTrigger: "always",
|
|
statusColorScheme: "accent",
|
|
openOnDoubleClick: false,
|
|
showFolderPaths: true,
|
|
hostClickBehavior: "newTab",
|
|
closeOnOpen: false,
|
|
barActions: {},
|
|
...defaultHostRowFields(),
|
|
},
|
|
};
|
|
}
|
|
|
|
const ACTION_ID_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,79}$/;
|
|
|
|
function sanitizeBarActions(input: unknown): Record<string, boolean> {
|
|
if (!input || typeof input !== "object" || Array.isArray(input)) return {};
|
|
const out: Record<string, boolean> = {};
|
|
for (const [id, value] of Object.entries(input as Record<string, unknown>)) {
|
|
if (ACTION_ID_PATTERN.test(id) && typeof value === "boolean") {
|
|
out[id] = value;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function sanitizeStringArray(input: unknown): string[] {
|
|
if (!Array.isArray(input)) return [];
|
|
return input.filter((v): v is string => typeof v === "string");
|
|
}
|
|
|
|
function sanitizeEnumArray<T extends string>(
|
|
input: unknown,
|
|
allowed: readonly T[],
|
|
): T[] {
|
|
if (!Array.isArray(input)) return [];
|
|
return input.filter((v): v is T => allowed.includes(v as T));
|
|
}
|
|
|
|
export function sanitizeHostSidebarPreferences(
|
|
input: unknown,
|
|
): HostSidebarPreferences {
|
|
const defaults = defaultHostSidebarPreferences();
|
|
if (!input || typeof input !== "object") return defaults;
|
|
const obj = input as Record<string, unknown>;
|
|
|
|
const sortObj = (obj.sort ?? {}) as Record<string, unknown>;
|
|
const sort = {
|
|
key: SORT_KEYS.includes(sortObj.key as SortKey)
|
|
? (sortObj.key as SortKey)
|
|
: defaults.sort.key,
|
|
pinnedFirst:
|
|
typeof sortObj.pinnedFirst === "boolean"
|
|
? sortObj.pinnedFirst
|
|
: defaults.sort.pinnedFirst,
|
|
};
|
|
|
|
const groupKey = GROUP_KEYS.includes(obj.groupKey as HostGroupKey)
|
|
? (obj.groupKey as HostGroupKey)
|
|
: defaults.groupKey;
|
|
|
|
const filtersObj = (obj.filters ?? {}) as Record<string, unknown>;
|
|
const filters: HostSidebarFilterState = {
|
|
status: sanitizeEnumArray(filtersObj.status, FILTER_STATUS),
|
|
authType: sanitizeStringArray(filtersObj.authType).filter((value) =>
|
|
AUTH_TYPE_PATTERN.test(value),
|
|
),
|
|
protocol: sanitizeStringArray(filtersObj.protocol).filter((value) =>
|
|
AUTH_TYPE_PATTERN.test(value),
|
|
),
|
|
features: sanitizeStringArray(filtersObj.features).filter((value) =>
|
|
AUTH_TYPE_PATTERN.test(value),
|
|
),
|
|
tags: sanitizeStringArray(filtersObj.tags),
|
|
};
|
|
|
|
const openFolders = sanitizeStringArray(obj.openFolders);
|
|
|
|
const displayObj = (obj.display ?? {}) as Record<string, unknown>;
|
|
const rowFields = {} as HostRowFields;
|
|
for (const key of HOST_ROW_FIELD_KEYS) {
|
|
rowFields[key] =
|
|
typeof displayObj[key] === "boolean"
|
|
? (displayObj[key] as boolean)
|
|
: defaults.display[key];
|
|
}
|
|
const display: HostSidebarDisplayPreferences = {
|
|
...rowFields,
|
|
density: DENSITIES.includes(displayObj.density as HostDensity)
|
|
? (displayObj.density as HostDensity)
|
|
: defaults.display.density,
|
|
showTags:
|
|
typeof displayObj.showTags === "boolean"
|
|
? displayObj.showTags
|
|
: defaults.display.showTags,
|
|
trayTrigger: TRAY_TRIGGERS.includes(
|
|
displayObj.trayTrigger as HostTrayTrigger,
|
|
)
|
|
? (displayObj.trayTrigger as HostTrayTrigger)
|
|
: defaults.display.trayTrigger,
|
|
statusColorScheme: STATUS_COLOR_SCHEMES.includes(
|
|
displayObj.statusColorScheme as StatusColorScheme,
|
|
)
|
|
? (displayObj.statusColorScheme as StatusColorScheme)
|
|
: defaults.display.statusColorScheme,
|
|
openOnDoubleClick:
|
|
typeof displayObj.openOnDoubleClick === "boolean"
|
|
? displayObj.openOnDoubleClick
|
|
: defaults.display.openOnDoubleClick,
|
|
showFolderPaths:
|
|
typeof displayObj.showFolderPaths === "boolean"
|
|
? displayObj.showFolderPaths
|
|
: defaults.display.showFolderPaths,
|
|
hostClickBehavior: HOST_CLICK_BEHAVIORS.includes(
|
|
displayObj.hostClickBehavior as HostClickBehavior,
|
|
)
|
|
? (displayObj.hostClickBehavior as HostClickBehavior)
|
|
: defaults.display.hostClickBehavior,
|
|
closeOnOpen:
|
|
typeof displayObj.closeOnOpen === "boolean"
|
|
? displayObj.closeOnOpen
|
|
: defaults.display.closeOnOpen,
|
|
barActions: sanitizeBarActions(displayObj.barActions),
|
|
};
|
|
|
|
return {
|
|
version: HOST_SIDEBAR_PREFS_VERSION,
|
|
sort,
|
|
groupKey,
|
|
filters,
|
|
openFolders,
|
|
display,
|
|
};
|
|
}
|