为主机添加可自定义图标和颜色 (#1504)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { executeHotkeyActionImpl, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { executeHotkeyActionImpl, getLogHostVisualSnapshot, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
@@ -169,3 +169,27 @@ test('quick switch hotkey toggles the quick switcher open state', () => {
|
||||
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, false);
|
||||
});
|
||||
|
||||
test('connection log host snapshot includes custom host icon fields', () => {
|
||||
assert.deepEqual(
|
||||
getLogHostVisualSnapshot({
|
||||
id: 'host-1',
|
||||
label: 'Database',
|
||||
hostname: 'db.example.com',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
distro: 'ubuntu',
|
||||
iconMode: 'custom',
|
||||
iconId: 'database',
|
||||
iconColor: 'blue',
|
||||
}),
|
||||
{
|
||||
hostOs: 'linux',
|
||||
hostDistro: 'ubuntu',
|
||||
hostIconMode: 'custom',
|
||||
hostIconId: 'database',
|
||||
hostIconColor: 'blue',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,16 +3,23 @@ import type React from 'react';
|
||||
import type { Host, HostProtocol } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { getEffectiveHostDistro } from '../../domain/host';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
|
||||
const getLogHostVisualSnapshot = (host: Host) => ({
|
||||
hostOs: host.os,
|
||||
hostDistro: getEffectiveHostDistro(host) || undefined,
|
||||
});
|
||||
export const getLogHostVisualSnapshot = (host: Host) => {
|
||||
const icon = sanitizeHostIconFields(host);
|
||||
return {
|
||||
hostOs: host.os,
|
||||
hostDistro: getEffectiveHostDistro(host) || undefined,
|
||||
hostIconMode: icon.iconMode,
|
||||
hostIconId: icon.iconId,
|
||||
hostIconColor: icon.iconColor,
|
||||
};
|
||||
};
|
||||
|
||||
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
|
||||
|
||||
@@ -467,7 +467,52 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.section.portCredentials': 'Port & Credentials',
|
||||
'hostDetails.section.appearance': 'Appearance',
|
||||
'hostDetails.distro.title': 'Linux Distribution',
|
||||
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
|
||||
'hostDetails.distro.desc': 'Controls the automatic host icon. A custom Host Icon overrides this display.',
|
||||
'hostDetails.icon.title': 'Host Icon',
|
||||
'hostDetails.icon.desc': 'Use automatic distro icons with optional color, or choose a built-in icon.',
|
||||
'hostDetails.icon.mode.auto': 'Automatic',
|
||||
'hostDetails.icon.mode.custom': 'Custom',
|
||||
'hostDetails.icon.reset': 'Reset host icon',
|
||||
'hostDetails.icon.showLibrary': 'Show icon library',
|
||||
'hostDetails.icon.hideLibrary': 'Hide icon library',
|
||||
'hostDetails.icon.autoUsesDistro': 'Use Linux Distribution icon and selected color for this host.',
|
||||
'hostDetails.icon.customOverridesDistro': 'Built-in icon replaces Linux Distribution for this host.',
|
||||
'hostDetails.icon.option.server': 'Server',
|
||||
'hostDetails.icon.option.terminal': 'Terminal',
|
||||
'hostDetails.icon.option.database': 'Database',
|
||||
'hostDetails.icon.option.cloud': 'Cloud',
|
||||
'hostDetails.icon.option.router': 'Router',
|
||||
'hostDetails.icon.option.shield': 'Shield',
|
||||
'hostDetails.icon.option.code': 'Code',
|
||||
'hostDetails.icon.option.box': 'Box',
|
||||
'hostDetails.icon.option.globe': 'Globe',
|
||||
'hostDetails.icon.option.cpu': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Storage',
|
||||
'hostDetails.icon.option.network': 'Network',
|
||||
'hostDetails.icon.option.wifi': 'Wireless',
|
||||
'hostDetails.icon.option.lock': 'Lock',
|
||||
'hostDetails.icon.option.key': 'Key',
|
||||
'hostDetails.icon.option.monitor': 'Monitor',
|
||||
'hostDetails.icon.option.container': 'Container',
|
||||
'hostDetails.icon.option.activity': 'Activity',
|
||||
'hostDetails.icon.option.zap': 'Fast',
|
||||
'hostDetails.icon.option.server-cog': 'Server settings',
|
||||
'hostDetails.icon.color.blue': 'Blue',
|
||||
'hostDetails.icon.color.green': 'Green',
|
||||
'hostDetails.icon.color.red': 'Red',
|
||||
'hostDetails.icon.color.amber': 'Amber',
|
||||
'hostDetails.icon.color.purple': 'Purple',
|
||||
'hostDetails.icon.color.cyan': 'Cyan',
|
||||
'hostDetails.icon.color.orange': 'Orange',
|
||||
'hostDetails.icon.color.slate': 'Slate',
|
||||
'hostDetails.icon.color.violet': 'Violet',
|
||||
'hostDetails.icon.color.pink': 'Pink',
|
||||
'hostDetails.icon.color.rose': 'Rose',
|
||||
'hostDetails.icon.color.lime': 'Lime',
|
||||
'hostDetails.icon.color.teal': 'Teal',
|
||||
'hostDetails.icon.color.sky': 'Sky',
|
||||
'hostDetails.icon.color.indigo': 'Indigo',
|
||||
'hostDetails.icon.color.zinc': 'Zinc',
|
||||
'hostDetails.distro.mode': 'Source',
|
||||
'hostDetails.distro.mode.auto': 'Auto-detect',
|
||||
'hostDetails.distro.mode.manual': 'Manual override',
|
||||
|
||||
@@ -502,7 +502,52 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.section.portCredentials': 'Порт и учётные данные',
|
||||
'hostDetails.section.appearance': 'Внешний вид',
|
||||
'hostDetails.distro.title': 'Дистрибутив Linux',
|
||||
'hostDetails.distro.desc': 'Автоопределение при подключении или ручное переопределение значка дистрибутива.',
|
||||
'hostDetails.distro.desc': 'Управляет автоматическим значком хоста. Свой значок хоста переопределяет это отображение.',
|
||||
'hostDetails.icon.title': 'Значок хоста',
|
||||
'hostDetails.icon.desc': 'Используйте автоматический значок дистрибутива с отдельным цветом или выберите встроенный значок.',
|
||||
'hostDetails.icon.mode.auto': 'Авто',
|
||||
'hostDetails.icon.mode.custom': 'Свой',
|
||||
'hostDetails.icon.reset': 'Сбросить значок',
|
||||
'hostDetails.icon.showLibrary': 'Показать библиотеку значков',
|
||||
'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков',
|
||||
'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.',
|
||||
'hostDetails.icon.customOverridesDistro': 'Встроенный значок заменяет значок дистрибутива Linux для этого хоста.',
|
||||
'hostDetails.icon.option.server': 'Сервер',
|
||||
'hostDetails.icon.option.terminal': 'Терминал',
|
||||
'hostDetails.icon.option.database': 'База данных',
|
||||
'hostDetails.icon.option.cloud': 'Облако',
|
||||
'hostDetails.icon.option.router': 'Маршрутизатор',
|
||||
'hostDetails.icon.option.shield': 'Защита',
|
||||
'hostDetails.icon.option.code': 'Код',
|
||||
'hostDetails.icon.option.box': 'Узел',
|
||||
'hostDetails.icon.option.globe': 'Глобус',
|
||||
'hostDetails.icon.option.cpu': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Хранилище',
|
||||
'hostDetails.icon.option.network': 'Сеть',
|
||||
'hostDetails.icon.option.wifi': 'Wi-Fi',
|
||||
'hostDetails.icon.option.lock': 'Замок',
|
||||
'hostDetails.icon.option.key': 'Ключ',
|
||||
'hostDetails.icon.option.monitor': 'Монитор',
|
||||
'hostDetails.icon.option.container': 'Контейнер',
|
||||
'hostDetails.icon.option.activity': 'Активность',
|
||||
'hostDetails.icon.option.zap': 'Быстрый',
|
||||
'hostDetails.icon.option.server-cog': 'Настройки сервера',
|
||||
'hostDetails.icon.color.blue': 'Синий',
|
||||
'hostDetails.icon.color.green': 'Зеленый',
|
||||
'hostDetails.icon.color.red': 'Красный',
|
||||
'hostDetails.icon.color.amber': 'Янтарный',
|
||||
'hostDetails.icon.color.purple': 'Фиолетовый',
|
||||
'hostDetails.icon.color.cyan': 'Голубой',
|
||||
'hostDetails.icon.color.orange': 'Оранжевый',
|
||||
'hostDetails.icon.color.slate': 'Серый',
|
||||
'hostDetails.icon.color.violet': 'Фиолетово-синий',
|
||||
'hostDetails.icon.color.pink': 'Розовый',
|
||||
'hostDetails.icon.color.rose': 'Розово-красный',
|
||||
'hostDetails.icon.color.lime': 'Лаймовый',
|
||||
'hostDetails.icon.color.teal': 'Бирюзовый',
|
||||
'hostDetails.icon.color.sky': 'Небесный',
|
||||
'hostDetails.icon.color.indigo': 'Индиго',
|
||||
'hostDetails.icon.color.zinc': 'Цинковый',
|
||||
'hostDetails.distro.mode': 'Источник',
|
||||
'hostDetails.distro.mode.auto': 'Автоопределение',
|
||||
'hostDetails.distro.mode.manual': 'Ручное переопределение',
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
|
||||
@@ -53,3 +54,24 @@ test("localized settings include terminal font weight option labels", () => {
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized vault messages include host icon labels", () => {
|
||||
const keys = [
|
||||
"hostDetails.icon.title",
|
||||
"hostDetails.icon.desc",
|
||||
"hostDetails.icon.mode.auto",
|
||||
"hostDetails.icon.mode.custom",
|
||||
"hostDetails.icon.reset",
|
||||
"hostDetails.icon.showLibrary",
|
||||
"hostDetails.icon.hideLibrary",
|
||||
"hostDetails.icon.autoUsesDistro",
|
||||
"hostDetails.icon.customOverridesDistro",
|
||||
...HOST_ICON_IDS.map((id) => `hostDetails.icon.option.${id}`),
|
||||
...HOST_ICON_COLORS.map((color) => `hostDetails.icon.color.${color.id}`),
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing host icon labels`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,7 +45,52 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.section.portCredentials': '端口与凭据',
|
||||
'hostDetails.section.appearance': '外观',
|
||||
'hostDetails.distro.title': 'Linux 发行版',
|
||||
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
|
||||
'hostDetails.distro.desc': '控制自动主机图标。自定义主机图标会覆盖此显示。',
|
||||
'hostDetails.icon.title': '主机图标',
|
||||
'hostDetails.icon.desc': '使用自动发行版图标并可单独改色,或选择内置图标。',
|
||||
'hostDetails.icon.mode.auto': '自动',
|
||||
'hostDetails.icon.mode.custom': '自定义',
|
||||
'hostDetails.icon.reset': '重置主机图标',
|
||||
'hostDetails.icon.showLibrary': '展开图标库',
|
||||
'hostDetails.icon.hideLibrary': '收起图标库',
|
||||
'hostDetails.icon.autoUsesDistro': '使用 Linux 发行版图标和所选颜色显示此主机。',
|
||||
'hostDetails.icon.customOverridesDistro': '内置图标会替换此主机的 Linux 发行版图标。',
|
||||
'hostDetails.icon.option.server': '服务器',
|
||||
'hostDetails.icon.option.terminal': '终端',
|
||||
'hostDetails.icon.option.database': '数据库',
|
||||
'hostDetails.icon.option.cloud': '云主机',
|
||||
'hostDetails.icon.option.router': '路由器',
|
||||
'hostDetails.icon.option.shield': '安全',
|
||||
'hostDetails.icon.option.code': '代码',
|
||||
'hostDetails.icon.option.box': '节点',
|
||||
'hostDetails.icon.option.globe': '公网',
|
||||
'hostDetails.icon.option.cpu': '计算',
|
||||
'hostDetails.icon.option.hard-drive': '存储',
|
||||
'hostDetails.icon.option.network': '网络',
|
||||
'hostDetails.icon.option.wifi': '无线',
|
||||
'hostDetails.icon.option.lock': '锁定',
|
||||
'hostDetails.icon.option.key': '密钥',
|
||||
'hostDetails.icon.option.monitor': '显示器',
|
||||
'hostDetails.icon.option.container': '容器',
|
||||
'hostDetails.icon.option.activity': '活动',
|
||||
'hostDetails.icon.option.zap': '高速',
|
||||
'hostDetails.icon.option.server-cog': '服务器设置',
|
||||
'hostDetails.icon.color.blue': '蓝色',
|
||||
'hostDetails.icon.color.green': '绿色',
|
||||
'hostDetails.icon.color.red': '红色',
|
||||
'hostDetails.icon.color.amber': '琥珀色',
|
||||
'hostDetails.icon.color.purple': '紫色',
|
||||
'hostDetails.icon.color.cyan': '青色',
|
||||
'hostDetails.icon.color.orange': '橙色',
|
||||
'hostDetails.icon.color.slate': '石板灰',
|
||||
'hostDetails.icon.color.violet': '紫罗兰',
|
||||
'hostDetails.icon.color.pink': '粉色',
|
||||
'hostDetails.icon.color.rose': '玫瑰红',
|
||||
'hostDetails.icon.color.lime': '青柠',
|
||||
'hostDetails.icon.color.teal': '蓝绿色',
|
||||
'hostDetails.icon.color.sky': '天蓝',
|
||||
'hostDetails.icon.color.indigo': '靛蓝',
|
||||
'hostDetails.icon.color.zinc': '锌灰',
|
||||
'hostDetails.distro.mode': '来源',
|
||||
'hostDetails.distro.mode.auto': '自动探测',
|
||||
'hostDetails.distro.mode.manual': '手动覆盖',
|
||||
|
||||
64
components/ConnectionLogsManager.test.tsx
Normal file
64
components/ConnectionLogsManager.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { ConnectionLog } from "../types.ts";
|
||||
import ConnectionLogsManager from "./ConnectionLogsManager.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const baseLog: ConnectionLog = {
|
||||
id: "log-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Database",
|
||||
hostname: "db.example.com",
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
hostOs: "linux",
|
||||
hostDistro: "ubuntu",
|
||||
startTime: 1_700_000_000_000,
|
||||
localUsername: "alice",
|
||||
localHostname: "workstation",
|
||||
saved: false,
|
||||
};
|
||||
|
||||
const renderLogs = (log: ConnectionLog) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<ConnectionLogsManager
|
||||
logs={[log]}
|
||||
hosts={[]}
|
||||
onToggleSaved={() => {}}
|
||||
onDelete={() => {}}
|
||||
onClearUnsaved={() => {}}
|
||||
onOpenLogView={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("ConnectionLogsManager renders saved custom host icon snapshots", () => {
|
||||
const markup = renderLogs({
|
||||
...baseLog,
|
||||
hostIconMode: "custom",
|
||||
hostIconId: "database",
|
||||
hostIconColor: "blue",
|
||||
});
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
|
||||
test("ConnectionLogsManager renders saved distro icon snapshots with custom colors", () => {
|
||||
const markup = renderLogs({
|
||||
...baseLog,
|
||||
hostIconMode: "auto",
|
||||
hostIconColor: "violet",
|
||||
});
|
||||
|
||||
assert.match(markup, /background-color:#7C3AED/i);
|
||||
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { resolveHostIconAppearance } from "../domain/hostIcon";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, Host } from "../types";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
@@ -67,7 +68,12 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
const isSerial = log.protocol === "serial";
|
||||
const hasPersistedHostIcon = !isLocal && !isSerial && !!log.hostDistro;
|
||||
const customHostIcon = resolveHostIconAppearance({
|
||||
iconMode: log.hostIconMode,
|
||||
iconId: log.hostIconId,
|
||||
iconColor: log.hostIconColor,
|
||||
});
|
||||
const hasPersistedHostIcon = !isLocal && !isSerial && (!!log.hostDistro || !!customHostIcon);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -101,6 +107,9 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
os: log.hostOs ?? "linux",
|
||||
distro: log.hostDistro,
|
||||
distroMode: "auto",
|
||||
iconMode: log.hostIconMode,
|
||||
iconId: log.hostIconId,
|
||||
iconColor: log.hostIconColor,
|
||||
}}
|
||||
fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
|
||||
size="log"
|
||||
|
||||
49
components/DistroAvatar.test.tsx
Normal file
49
components/DistroAvatar.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { DistroAvatar } from "./DistroAvatar.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
|
||||
const baseHost: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os" | "protocol" | "iconMode" | "iconId" | "iconColor"> = {
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
test("DistroAvatar renders custom host icon before distro color", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu", iconMode: "custom", iconId: "database", iconColor: "blue" }}
|
||||
fallback="DB"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
|
||||
test("DistroAvatar keeps serial hosts on the USB icon", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, protocol: "serial", iconMode: "custom", iconId: "database", iconColor: "blue" }}
|
||||
fallback="S"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /bg-amber-600/);
|
||||
assert.doesNotMatch(markup, /background-color:#2563EB/i);
|
||||
});
|
||||
|
||||
test("DistroAvatar keeps distro icon and applies custom palette color when icon mode is automatic", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu", iconMode: "auto", iconId: "database", iconColor: "blue" }}
|
||||
fallback="U"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Server, Usb } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
import { getEffectiveHostDistro } from "../domain/host";
|
||||
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from "../domain/hostIcon";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host } from "../types";
|
||||
import { renderHostIconGlyph } from "./hostIconRenderer";
|
||||
|
||||
export const DISTRO_LOGOS: Record<string, string> = {
|
||||
ubuntu: "/distro/ubuntu.svg",
|
||||
@@ -71,7 +73,7 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
|
||||
type DistroAvatarProps = {
|
||||
host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> &
|
||||
Partial<Pick<Host, "protocol">>;
|
||||
Partial<Pick<Host, "protocol" | "iconMode" | "iconId" | "iconColor">>;
|
||||
fallback: string;
|
||||
className?: string;
|
||||
/** xs matches top tab bar icons (h-4 rounded rect) */
|
||||
@@ -125,15 +127,33 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const customAppearance = resolveHostIconAppearance(host);
|
||||
const customColor = resolveHostIconColorAppearance(host);
|
||||
if (customAppearance) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center text-white",
|
||||
containerClass,
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: customAppearance.colorHex }}
|
||||
>
|
||||
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logo && !errored) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center overflow-hidden",
|
||||
containerClass,
|
||||
bg,
|
||||
!customColor && bg,
|
||||
className,
|
||||
)}
|
||||
style={customColor ? { backgroundColor: customColor.colorHex } : undefined}
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRoun
|
||||
import type { Host } from "../types";
|
||||
import { cn } from "../lib/utils";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { HostIconPicker } from "./HostIconPicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
|
||||
@@ -71,6 +72,28 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<DistroAvatar host={form as Host} fallback="H" size="sm" />}
|
||||
title={t("hostDetails.icon.title")}
|
||||
hint={t("hostDetails.icon.desc")}
|
||||
>
|
||||
<HostIconPicker
|
||||
iconMode={form.iconMode}
|
||||
iconId={form.iconId}
|
||||
iconColor={form.iconColor}
|
||||
onChange={(next) => {
|
||||
update("iconMode", next.iconMode);
|
||||
update("iconId", next.iconId);
|
||||
update("iconColor", next.iconColor);
|
||||
}}
|
||||
onReset={() => {
|
||||
update("iconMode", undefined);
|
||||
update("iconId", undefined);
|
||||
update("iconColor", undefined);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<KeyRound size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.portCredentials")}
|
||||
|
||||
@@ -296,3 +296,19 @@ test("HostDetailsPanel does not offer to disable telnet when telnet is the prima
|
||||
assert.ok(telnetHeader);
|
||||
assert.doesNotMatch(telnetHeader[0], /hover:text-destructive/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel shows host icon customization in the connection settings", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
iconMode: "custom",
|
||||
iconId: "database",
|
||||
iconColor: "blue",
|
||||
});
|
||||
|
||||
assert.match(markup, /Host Icon/);
|
||||
assert.match(markup, /Database/);
|
||||
assert.match(markup, /Violet/);
|
||||
assert.match(markup, /Built-in icon replaces Linux Distribution/);
|
||||
assert.match(markup, /IP or Hostname/);
|
||||
});
|
||||
|
||||
70
components/HostIconPicker.test.tsx
Normal file
70
components/HostIconPicker.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { HostIconPicker } from "./HostIconPicker.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const renderPicker = (props: Partial<React.ComponentProps<typeof HostIconPicker>> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<HostIconPicker
|
||||
iconMode={props.iconMode}
|
||||
iconId={props.iconId}
|
||||
iconColor={props.iconColor}
|
||||
onChange={() => {}}
|
||||
onReset={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("HostIconPicker renders automatic mode without selected custom defaults", () => {
|
||||
const markup = renderPicker();
|
||||
|
||||
assert.match(markup, /Automatic/);
|
||||
assert.doesNotMatch(markup, /aria-pressed="true"[^>]*Database/);
|
||||
});
|
||||
|
||||
test("HostIconPicker renders custom choices and reset when custom", () => {
|
||||
const markup = renderPicker({ iconMode: "custom", iconId: "database", iconColor: "blue" });
|
||||
|
||||
assert.match(markup, /Database/);
|
||||
assert.match(markup, /Globe/);
|
||||
assert.match(markup, /Show icon library/);
|
||||
assert.doesNotMatch(markup, /Server settings/);
|
||||
assert.match(markup, /grid-cols-5/);
|
||||
assert.match(markup, /Blue/);
|
||||
assert.match(markup, /Reset/);
|
||||
assert.match(markup, /Built-in icon replaces Linux Distribution for this host/);
|
||||
});
|
||||
|
||||
test("HostIconPicker shows two rows of color swatches in automatic mode", () => {
|
||||
const markup = renderPicker({ iconMode: "auto", iconColor: "violet" });
|
||||
|
||||
assert.match(markup, /Violet/);
|
||||
assert.match(markup, /grid-cols-8/);
|
||||
assert.match(markup, /Use Linux Distribution icon and selected color/);
|
||||
});
|
||||
|
||||
test("HostIconPicker does not expose image upload", () => {
|
||||
const markup = renderPicker({ iconMode: "custom", iconId: "database", iconColor: "blue" });
|
||||
|
||||
assert.doesNotMatch(markup, /upload/i);
|
||||
assert.doesNotMatch(markup, /choose file/i);
|
||||
});
|
||||
|
||||
test("HostIconPicker normalizes invalid incoming custom values only for editing", () => {
|
||||
const markup = renderPicker({
|
||||
iconMode: "custom",
|
||||
iconId: "bad" as React.ComponentProps<typeof HostIconPicker>["iconId"],
|
||||
iconColor: "bad" as React.ComponentProps<typeof HostIconPicker>["iconColor"],
|
||||
});
|
||||
|
||||
assert.match(markup, /Server/);
|
||||
assert.match(markup, /Blue/);
|
||||
assert.match(markup, /Built-in icon replaces Linux Distribution/);
|
||||
});
|
||||
164
components/HostIconPicker.tsx
Normal file
164
components/HostIconPicker.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import {
|
||||
DEFAULT_HOST_ICON_COLOR,
|
||||
DEFAULT_HOST_ICON_ID,
|
||||
HOST_ICON_COLORS,
|
||||
HOST_ICON_IDS,
|
||||
isHostIconColorId,
|
||||
isHostIconId,
|
||||
normalizeHostIconSelection,
|
||||
} from "../domain/hostIcon";
|
||||
import type { HostIconColorId, HostIconId, HostIconMode } from "../domain/models";
|
||||
import { cn } from "../lib/utils";
|
||||
import { renderHostIconGlyph } from "./hostIconRenderer";
|
||||
import { Button } from "./ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type HostIconPickerProps = {
|
||||
iconMode?: HostIconMode;
|
||||
iconId?: HostIconId;
|
||||
iconColor?: HostIconColorId;
|
||||
onChange: (next: { iconMode?: HostIconMode; iconId?: HostIconId; iconColor?: HostIconColorId }) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
export const HostIconPicker: React.FC<HostIconPickerProps> = ({
|
||||
iconMode,
|
||||
iconId,
|
||||
iconColor,
|
||||
onChange,
|
||||
onReset,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const custom = iconMode === "custom";
|
||||
const normalizedSelection = normalizeHostIconSelection({ iconMode, iconId, iconColor });
|
||||
const selectedIconId = custom && isHostIconId(normalizedSelection.iconId)
|
||||
? normalizedSelection.iconId
|
||||
: DEFAULT_HOST_ICON_ID;
|
||||
const hasCustomColor = isHostIconColorId(normalizedSelection.iconColor);
|
||||
const selectedColor = hasCustomColor ? normalizedSelection.iconColor : DEFAULT_HOST_ICON_COLOR;
|
||||
const selectedColorHex =
|
||||
HOST_ICON_COLORS.find((color) => color.id === selectedColor)?.hex || HOST_ICON_COLORS[0].hex;
|
||||
|
||||
const setCustom = () => onChange({ iconMode: "custom", iconId: selectedIconId, iconColor: selectedColor });
|
||||
const updateIcon = (nextIconId: HostIconId) =>
|
||||
onChange({ iconMode: "custom", iconId: nextIconId, iconColor: selectedColor });
|
||||
const updateColor = (nextColor: HostIconColorId) => {
|
||||
if (custom) {
|
||||
onChange({ iconMode: "custom", iconId: selectedIconId, iconColor: nextColor });
|
||||
return;
|
||||
}
|
||||
onChange({ iconMode: "auto", iconColor: nextColor });
|
||||
};
|
||||
const visibleIconIds = custom && !expanded ? HOST_ICON_IDS.slice(0, 10) : HOST_ICON_IDS;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={custom ? "ghost" : "secondary"}
|
||||
size="sm"
|
||||
className="h-8 flex-1"
|
||||
onClick={onReset}
|
||||
>
|
||||
{t("hostDetails.icon.mode.auto")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={custom ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 flex-1"
|
||||
onClick={setCustom}
|
||||
>
|
||||
{t("hostDetails.icon.mode.custom")}
|
||||
</Button>
|
||||
{(custom || hasCustomColor) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={onReset}
|
||||
aria-label={t("hostDetails.icon.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{custom && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{visibleIconIds.map((optionIconId) => {
|
||||
const selected = selectedIconId === optionIconId;
|
||||
return (
|
||||
<Tooltip key={optionIconId}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(`hostDetails.icon.option.${optionIconId}`)}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex h-9 items-center justify-center rounded-md border text-muted-foreground transition-colors hover:bg-secondary",
|
||||
selected ? "border-primary bg-primary/10 text-primary" : "border-border/60 bg-background/60",
|
||||
)}
|
||||
onClick={() => updateIcon(optionIconId)}
|
||||
>
|
||||
{renderHostIconGlyph(optionIconId, "h-4 w-4")}
|
||||
<span className="sr-only">{t(`hostDetails.icon.option.${optionIconId}`)}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t(`hostDetails.icon.option.${optionIconId}`)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{t(expanded ? "hostDetails.icon.hideLibrary" : "hostDetails.icon.showLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{HOST_ICON_COLORS.map((color) => {
|
||||
const selected = hasCustomColor && selectedColor === color.id;
|
||||
return (
|
||||
<Tooltip key={color.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(`hostDetails.icon.color.${color.id}`)}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"h-7 rounded-md border transition-transform hover:scale-105",
|
||||
selected ? "border-primary ring-2 ring-primary/30" : "border-border/60",
|
||||
)}
|
||||
style={{ backgroundColor: color.hex }}
|
||||
onClick={() => updateColor(color.id)}
|
||||
>
|
||||
<span className="sr-only">{t(`hostDetails.icon.color.${color.id}`)}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t(`hostDetails.icon.color.${color.id}`)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-secondary/40 px-2.5 py-2 text-xs text-muted-foreground">
|
||||
<span className="h-4 w-4 rounded" style={{ backgroundColor: hasCustomColor ? selectedColorHex : undefined }} />
|
||||
<span>{t(custom ? "hostDetails.icon.customOverridesDistro" : "hostDetails.icon.autoUsesDistro")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -103,6 +103,15 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
|
||||
assert.match(topTabsSource, /data-section="top-tabs-quick-switcher-toggle"/);
|
||||
});
|
||||
|
||||
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
|
||||
const source = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /resolveHostIconAppearance\(host\)/);
|
||||
assert.ok(
|
||||
source.indexOf("resolveHostIconAppearance(host)") < source.indexOf("getEffectiveHostDistro(host)"),
|
||||
"custom host icon should be checked before distro fallback",
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace session drag data is recognized with a dedicated drag type", () => {
|
||||
const data = new Map([
|
||||
[WORKSPACE_SESSION_DRAG_TYPE, "session-1"],
|
||||
|
||||
75
components/hostIconRenderer.tsx
Normal file
75
components/hostIconRenderer.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Activity,
|
||||
Box,
|
||||
Cloud,
|
||||
Code2,
|
||||
Container,
|
||||
Cpu,
|
||||
Database,
|
||||
Globe2,
|
||||
HardDrive,
|
||||
KeyRound,
|
||||
Lock,
|
||||
Monitor,
|
||||
Network,
|
||||
Router,
|
||||
Server,
|
||||
ServerCog,
|
||||
Shield,
|
||||
SquareTerminal,
|
||||
Wifi,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import type { HostIconId } from "../domain/models";
|
||||
|
||||
const HOST_ICON_COMPONENTS = {
|
||||
server: Server,
|
||||
terminal: SquareTerminal,
|
||||
database: Database,
|
||||
cloud: Cloud,
|
||||
router: Router,
|
||||
shield: Shield,
|
||||
code: Code2,
|
||||
box: Box,
|
||||
globe: Globe2,
|
||||
cpu: Cpu,
|
||||
"hard-drive": HardDrive,
|
||||
network: Network,
|
||||
wifi: Wifi,
|
||||
lock: Lock,
|
||||
key: KeyRound,
|
||||
monitor: Monitor,
|
||||
container: Container,
|
||||
activity: Activity,
|
||||
zap: Zap,
|
||||
"server-cog": ServerCog,
|
||||
} as const satisfies Record<HostIconId, React.ComponentType<{ className?: string; size?: number }>>;
|
||||
|
||||
export const HOST_ICON_LABEL_KEYS: Record<HostIconId, string> = {
|
||||
server: "hostDetails.icon.option.server",
|
||||
terminal: "hostDetails.icon.option.terminal",
|
||||
database: "hostDetails.icon.option.database",
|
||||
cloud: "hostDetails.icon.option.cloud",
|
||||
router: "hostDetails.icon.option.router",
|
||||
shield: "hostDetails.icon.option.shield",
|
||||
code: "hostDetails.icon.option.code",
|
||||
box: "hostDetails.icon.option.box",
|
||||
globe: "hostDetails.icon.option.globe",
|
||||
cpu: "hostDetails.icon.option.cpu",
|
||||
"hard-drive": "hostDetails.icon.option.hard-drive",
|
||||
network: "hostDetails.icon.option.network",
|
||||
wifi: "hostDetails.icon.option.wifi",
|
||||
lock: "hostDetails.icon.option.lock",
|
||||
key: "hostDetails.icon.option.key",
|
||||
monitor: "hostDetails.icon.option.monitor",
|
||||
container: "hostDetails.icon.option.container",
|
||||
activity: "hostDetails.icon.option.activity",
|
||||
zap: "hostDetails.icon.option.zap",
|
||||
"server-cog": "hostDetails.icon.option.server-cog",
|
||||
};
|
||||
|
||||
export const renderHostIconGlyph = (iconId: HostIconId, className?: string): React.ReactNode => {
|
||||
const Icon = HOST_ICON_COMPONENTS[iconId] || Server;
|
||||
return <Icon className={className} />;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import type { LogView } from '../../application/state/logViewState';
|
||||
import { useWindowControls } from '../../application/state/useWindowControls';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { getEffectiveHostDistro } from '../../domain/host';
|
||||
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from '../../domain/hostIcon';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
|
||||
@@ -14,6 +15,7 @@ import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/t
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
|
||||
import { renderHostIconGlyph } from '../hostIconRenderer';
|
||||
|
||||
// File extensions that render the code-file icon instead of the plain text icon.
|
||||
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
|
||||
@@ -78,14 +80,26 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
);
|
||||
}
|
||||
|
||||
if (host) {
|
||||
const customAppearance = resolveHostIconAppearance(host);
|
||||
if (customAppearance) {
|
||||
return (
|
||||
<div className={cn(boxBase, "text-white")} style={{ backgroundColor: customAppearance.colorHex }}>
|
||||
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Try distro logo with brand background color
|
||||
if (host) {
|
||||
const distro = getEffectiveHostDistro(host);
|
||||
const logo = DISTRO_LOGOS[distro];
|
||||
if (logo) {
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
const customColor = resolveHostIconColorAppearance(host);
|
||||
return (
|
||||
<div className={cn(boxBase, bg)}>
|
||||
<div className={cn(boxBase, !customColor && bg)} style={customColor ? { backgroundColor: customColor.colorHex } : undefined}>
|
||||
<img
|
||||
src={logo}
|
||||
alt={distro || host.os}
|
||||
|
||||
@@ -23,7 +23,9 @@ const makeHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
hostname: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
authMethod: "password",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
createdAt: 1,
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
@@ -136,6 +138,41 @@ test("migrateHostsFromLegacyLineTimestamps fills only missing host choices", ()
|
||||
]);
|
||||
});
|
||||
|
||||
test("sanitizeHost preserves valid custom host icon fields", () => {
|
||||
const sanitized = sanitizeHost(makeHost({
|
||||
iconMode: "custom",
|
||||
iconId: "database",
|
||||
iconColor: "blue",
|
||||
}));
|
||||
|
||||
assert.equal(sanitized.iconMode, "custom");
|
||||
assert.equal(sanitized.iconId, "database");
|
||||
assert.equal(sanitized.iconColor, "blue");
|
||||
});
|
||||
|
||||
test("sanitizeHost preserves automatic host icon color fields", () => {
|
||||
const sanitized = sanitizeHost(makeHost({
|
||||
iconMode: "auto",
|
||||
iconColor: "violet",
|
||||
}));
|
||||
|
||||
assert.equal(sanitized.iconMode, "auto");
|
||||
assert.equal(sanitized.iconId, undefined);
|
||||
assert.equal(sanitized.iconColor, "violet");
|
||||
});
|
||||
|
||||
test("sanitizeHost removes invalid custom host icon fields", () => {
|
||||
const sanitized = sanitizeHost(makeHost({
|
||||
iconMode: "custom",
|
||||
iconId: "bad",
|
||||
iconColor: "blue",
|
||||
} as unknown as Partial<Host>));
|
||||
|
||||
assert.equal(sanitized.iconMode, undefined);
|
||||
assert.equal(sanitized.iconId, undefined);
|
||||
assert.equal(sanitized.iconColor, undefined);
|
||||
});
|
||||
|
||||
test("preserves a concurrent terminal timestamp toggle when host details did not edit it", () => {
|
||||
const openedHost = makeHost({ showLineTimestamps: false });
|
||||
const latestHost = makeHost({ showLineTimestamps: true });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Host, TerminalSettings } from './models';
|
||||
import { sanitizeHostIconFields } from './hostIcon';
|
||||
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
|
||||
|
||||
export type HostLabelRenameResult =
|
||||
@@ -326,6 +327,7 @@ export const sanitizeHost = (host: Host): Host => {
|
||||
: host.distroMode === 'auto'
|
||||
? 'auto'
|
||||
: undefined;
|
||||
const cleanHostIcon = sanitizeHostIconFields(host);
|
||||
const migrated = migrateDeprecatedFontOverride(host);
|
||||
const cleanNotes = host.notes?.trim() || undefined;
|
||||
return {
|
||||
@@ -334,6 +336,10 @@ export const sanitizeHost = (host: Host): Host => {
|
||||
distro: cleanDistro,
|
||||
distroMode: cleanDistroMode,
|
||||
manualDistro: cleanManualDistro || undefined,
|
||||
iconMode: undefined,
|
||||
iconId: undefined,
|
||||
iconColor: undefined,
|
||||
...cleanHostIcon,
|
||||
notes: cleanNotes,
|
||||
};
|
||||
};
|
||||
|
||||
78
domain/hostIcon.test.ts
Normal file
78
domain/hostIcon.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_HOST_ICON_COLOR,
|
||||
DEFAULT_HOST_ICON_ID,
|
||||
HOST_ICON_COLORS,
|
||||
clearHostIconAppearance,
|
||||
isHostIconColorId,
|
||||
isHostIconId,
|
||||
normalizeHostIconSelection,
|
||||
resolveHostIconAppearance,
|
||||
sanitizeHostIconFields,
|
||||
} from "./hostIcon.ts";
|
||||
|
||||
test("resolveHostIconAppearance returns null for automatic hosts", () => {
|
||||
assert.equal(resolveHostIconAppearance({}), null);
|
||||
assert.equal(resolveHostIconAppearance({ iconMode: "auto", iconId: "database", iconColor: "blue" }), null);
|
||||
});
|
||||
|
||||
test("automatic hosts may keep a custom palette color without a custom icon", () => {
|
||||
assert.deepEqual(sanitizeHostIconFields({ iconMode: "auto", iconColor: "violet" }), {
|
||||
iconMode: "auto",
|
||||
iconColor: "violet",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveHostIconAppearance returns validated custom icon and color", () => {
|
||||
assert.deepEqual(
|
||||
resolveHostIconAppearance({ iconMode: "custom", iconId: "database", iconColor: "blue" }),
|
||||
{ iconId: "database", colorId: "blue", colorHex: "#2563EB" },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostIconAppearance ignores invalid custom data", () => {
|
||||
assert.equal(
|
||||
resolveHostIconAppearance({ iconMode: "custom", iconId: "bad", iconColor: "blue" } as unknown as Parameters<typeof resolveHostIconAppearance>[0]),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveHostIconAppearance({ iconMode: "custom", iconId: "server", iconColor: "#123456" } as unknown as Parameters<typeof resolveHostIconAppearance>[0]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeHostIconSelection creates a complete UI custom selection", () => {
|
||||
assert.deepEqual(normalizeHostIconSelection({ iconMode: "custom" }), {
|
||||
iconMode: "custom",
|
||||
iconId: DEFAULT_HOST_ICON_ID,
|
||||
iconColor: DEFAULT_HOST_ICON_COLOR,
|
||||
});
|
||||
});
|
||||
|
||||
test("sanitizeHostIconFields clears incomplete or invalid stored custom data", () => {
|
||||
assert.deepEqual(sanitizeHostIconFields({ iconMode: "custom" }), {});
|
||||
assert.deepEqual(
|
||||
sanitizeHostIconFields({ iconMode: "custom", iconId: "bad", iconColor: "blue" } as unknown as Parameters<typeof sanitizeHostIconFields>[0]),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test("clearHostIconAppearance removes custom icon fields", () => {
|
||||
assert.deepEqual(
|
||||
clearHostIconAppearance({ iconMode: "custom", iconId: "database", iconColor: "blue", label: "DB" }),
|
||||
{ label: "DB" },
|
||||
);
|
||||
});
|
||||
|
||||
test("host icon validators accept only curated IDs and color IDs", () => {
|
||||
assert.equal(isHostIconId("server"), true);
|
||||
assert.equal(isHostIconId("globe"), true);
|
||||
assert.equal(isHostIconId("server-cog"), true);
|
||||
assert.equal(isHostIconId("uploaded-file"), false);
|
||||
assert.equal(isHostIconColorId(HOST_ICON_COLORS[0].id), true);
|
||||
assert.equal(isHostIconColorId("violet"), true);
|
||||
assert.equal(HOST_ICON_COLORS.length, 16);
|
||||
assert.equal(isHostIconColorId("#2563EB"), false);
|
||||
});
|
||||
120
domain/hostIcon.ts
Normal file
120
domain/hostIcon.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Host, HostIconColorId, HostIconId, HostIconMode } from "./models";
|
||||
|
||||
export const DEFAULT_HOST_ICON_ID: HostIconId = "server";
|
||||
export const DEFAULT_HOST_ICON_COLOR: HostIconColorId = "blue";
|
||||
|
||||
export const HOST_ICON_IDS = [
|
||||
"server",
|
||||
"terminal",
|
||||
"database",
|
||||
"cloud",
|
||||
"router",
|
||||
"shield",
|
||||
"code",
|
||||
"box",
|
||||
"globe",
|
||||
"cpu",
|
||||
"hard-drive",
|
||||
"network",
|
||||
"wifi",
|
||||
"lock",
|
||||
"key",
|
||||
"monitor",
|
||||
"container",
|
||||
"activity",
|
||||
"zap",
|
||||
"server-cog",
|
||||
] as const satisfies readonly HostIconId[];
|
||||
|
||||
export const HOST_ICON_COLORS = [
|
||||
{ id: "blue", hex: "#2563EB" },
|
||||
{ id: "green", hex: "#16A34A" },
|
||||
{ id: "red", hex: "#DC2626" },
|
||||
{ id: "amber", hex: "#B45309" },
|
||||
{ id: "purple", hex: "#9333EA" },
|
||||
{ id: "cyan", hex: "#0891B2" },
|
||||
{ id: "orange", hex: "#EA580C" },
|
||||
{ id: "slate", hex: "#475569" },
|
||||
{ id: "violet", hex: "#7C3AED" },
|
||||
{ id: "pink", hex: "#DB2777" },
|
||||
{ id: "rose", hex: "#E11D48" },
|
||||
{ id: "lime", hex: "#65A30D" },
|
||||
{ id: "teal", hex: "#0D9488" },
|
||||
{ id: "sky", hex: "#0284C7" },
|
||||
{ id: "indigo", hex: "#4F46E5" },
|
||||
{ id: "zinc", hex: "#52525B" },
|
||||
] as const satisfies readonly { id: HostIconColorId; hex: string }[];
|
||||
|
||||
export type HostIconAppearance = {
|
||||
iconId: HostIconId;
|
||||
colorId: HostIconColorId;
|
||||
colorHex: string;
|
||||
};
|
||||
|
||||
export type HostIconColorAppearance = {
|
||||
colorId: HostIconColorId;
|
||||
colorHex: string;
|
||||
};
|
||||
|
||||
export const isHostIconMode = (value: unknown): value is HostIconMode =>
|
||||
value === "auto" || value === "custom";
|
||||
|
||||
export const isHostIconId = (value: unknown): value is HostIconId =>
|
||||
typeof value === "string" && (HOST_ICON_IDS as readonly string[]).includes(value);
|
||||
|
||||
export const isHostIconColorId = (value: unknown): value is HostIconColorId =>
|
||||
typeof value === "string" && HOST_ICON_COLORS.some((color) => color.id === value);
|
||||
|
||||
const resolveColorHex = (colorId: HostIconColorId): string =>
|
||||
HOST_ICON_COLORS.find((color) => color.id === colorId)?.hex || HOST_ICON_COLORS[0].hex;
|
||||
|
||||
export const resolveHostIconColorAppearance = (
|
||||
host: Partial<Pick<Host, "iconColor">>,
|
||||
): HostIconColorAppearance | null => {
|
||||
if (!isHostIconColorId(host.iconColor)) return null;
|
||||
return {
|
||||
colorId: host.iconColor,
|
||||
colorHex: resolveColorHex(host.iconColor),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveHostIconAppearance = (
|
||||
host: Partial<Pick<Host, "iconMode" | "iconId" | "iconColor">>,
|
||||
): HostIconAppearance | null => {
|
||||
if (host.iconMode !== "custom") return null;
|
||||
if (!isHostIconId(host.iconId) || !isHostIconColorId(host.iconColor)) return null;
|
||||
return {
|
||||
iconId: host.iconId,
|
||||
colorId: host.iconColor,
|
||||
colorHex: resolveColorHex(host.iconColor),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeHostIconSelection = <T extends Partial<Pick<Host, "iconMode" | "iconId" | "iconColor">>>(
|
||||
host: T,
|
||||
): Pick<Host, "iconMode" | "iconId" | "iconColor"> => {
|
||||
if (host.iconMode !== "custom") {
|
||||
const iconColor = isHostIconColorId(host.iconColor) ? host.iconColor : undefined;
|
||||
return iconColor ? { iconMode: "auto", iconColor } : {};
|
||||
}
|
||||
const iconId = isHostIconId(host.iconId) ? host.iconId : DEFAULT_HOST_ICON_ID;
|
||||
const iconColor = isHostIconColorId(host.iconColor) ? host.iconColor : DEFAULT_HOST_ICON_COLOR;
|
||||
return { iconMode: "custom", iconId, iconColor };
|
||||
};
|
||||
|
||||
export const sanitizeHostIconFields = <T extends Partial<Pick<Host, "iconMode" | "iconId" | "iconColor">>>(
|
||||
host: T,
|
||||
): Pick<Host, "iconMode" | "iconId" | "iconColor"> => {
|
||||
if (host.iconMode !== "custom") {
|
||||
return isHostIconColorId(host.iconColor) ? { iconMode: "auto", iconColor: host.iconColor } : {};
|
||||
}
|
||||
if (!isHostIconId(host.iconId) || !isHostIconColorId(host.iconColor)) return {};
|
||||
return { iconMode: "custom", iconId: host.iconId, iconColor: host.iconColor };
|
||||
};
|
||||
|
||||
export const clearHostIconAppearance = <T extends Record<string, unknown>>(
|
||||
host: T,
|
||||
): Omit<T, "iconMode" | "iconId" | "iconColor"> => {
|
||||
const { iconMode: _iconMode, iconId: _iconId, iconColor: _iconColor, ...rest } = host;
|
||||
return rest;
|
||||
};
|
||||
@@ -49,6 +49,45 @@ export interface EnvVar {
|
||||
|
||||
// Protocol type for connections
|
||||
export type HostProtocol = 'ssh' | 'telnet' | 'mosh' | 'et' | 'local' | 'serial';
|
||||
export type HostIconMode = 'auto' | 'custom';
|
||||
export type HostIconId =
|
||||
| 'server'
|
||||
| 'terminal'
|
||||
| 'database'
|
||||
| 'cloud'
|
||||
| 'router'
|
||||
| 'shield'
|
||||
| 'code'
|
||||
| 'box'
|
||||
| 'globe'
|
||||
| 'cpu'
|
||||
| 'hard-drive'
|
||||
| 'network'
|
||||
| 'wifi'
|
||||
| 'lock'
|
||||
| 'key'
|
||||
| 'monitor'
|
||||
| 'container'
|
||||
| 'activity'
|
||||
| 'zap'
|
||||
| 'server-cog';
|
||||
export type HostIconColorId =
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'red'
|
||||
| 'amber'
|
||||
| 'purple'
|
||||
| 'cyan'
|
||||
| 'orange'
|
||||
| 'slate'
|
||||
| 'violet'
|
||||
| 'pink'
|
||||
| 'rose'
|
||||
| 'lime'
|
||||
| 'teal'
|
||||
| 'sky'
|
||||
| 'indigo'
|
||||
| 'zinc';
|
||||
|
||||
// Serial port configuration
|
||||
export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space';
|
||||
@@ -131,6 +170,9 @@ export interface Host {
|
||||
distro?: string; // detected distro id (e.g., ubuntu, debian)
|
||||
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
|
||||
manualDistro?: string; // manually selected distro id when distroMode='manual'
|
||||
iconMode?: HostIconMode; // Optional host icon mode. Missing/auto preserves distro detection.
|
||||
iconId?: HostIconId; // Curated icon override used when iconMode='custom'
|
||||
iconColor?: HostIconColorId; // Palette color used with automatic or custom host icons
|
||||
// Multi-protocol support
|
||||
protocols?: ProtocolConfig[]; // Multiple protocol configurations
|
||||
telnetPort?: number; // Telnet-specific port (for quick access)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Known Hosts - discovered from system SSH known_hosts file
|
||||
import type { HostIconColorId, HostIconId, HostIconMode } from './connection';
|
||||
|
||||
export interface KnownHost {
|
||||
id: string;
|
||||
hostname: string; // The host pattern from known_hosts
|
||||
@@ -46,6 +48,9 @@ export interface ConnectionLog {
|
||||
protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'et' | 'serial';
|
||||
hostOs?: 'linux' | 'windows' | 'macos'; // Snapshot of the connected host OS for log icons
|
||||
hostDistro?: string; // Snapshot of the connected host distro/vendor icon id
|
||||
hostIconMode?: HostIconMode; // Snapshot of the host icon mode for log icons
|
||||
hostIconId?: HostIconId; // Snapshot of the built-in host icon id
|
||||
hostIconColor?: HostIconColorId; // Snapshot of the host icon color id
|
||||
startTime: number; // Connection start timestamp
|
||||
endTime?: number; // Connection end timestamp (undefined if still active)
|
||||
localUsername: string; // System username of the local user
|
||||
|
||||
Reference in New Issue
Block a user