为主机添加可自定义图标和颜色 (#1504)

This commit is contained in:
Ryanisgood
2026-06-17 23:32:36 +08:00
committed by GitHub
parent 46755465f9
commit 52bc48f73a
23 changed files with 1002 additions and 13 deletions

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; 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 { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.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); executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
assert.equal(isQuickSwitcherOpen, false); 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',
},
);
});

View File

@@ -3,16 +3,23 @@ import type React from 'react';
import type { Host, HostProtocol } from '../../types'; import type { Host, HostProtocol } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal'; import type { PassphraseRequest } from '../../components/PassphraseModal';
import { getEffectiveHostDistro } from '../../domain/host'; import { getEffectiveHostDistro } from '../../domain/host';
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys'; import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets'; import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
type AppContextGetter = () => Record<string, any>; type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions(); const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
const getLogHostVisualSnapshot = (host: Host) => ({ export const getLogHostVisualSnapshot = (host: Host) => {
hostOs: host.os, const icon = sanitizeHostIconFields(host);
hostDistro: getEffectiveHostDistro(host) || undefined, return {
}); hostOs: host.os,
hostDistro: getEffectiveHostDistro(host) || undefined,
hostIconMode: icon.iconMode,
hostIconId: icon.iconId,
hostIconColor: icon.iconColor,
};
};
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) { export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx(); const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();

View File

@@ -467,7 +467,52 @@ export const enVaultMessages: Messages = {
'hostDetails.section.portCredentials': 'Port & Credentials', 'hostDetails.section.portCredentials': 'Port & Credentials',
'hostDetails.section.appearance': 'Appearance', 'hostDetails.section.appearance': 'Appearance',
'hostDetails.distro.title': 'Linux Distribution', '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': 'Source',
'hostDetails.distro.mode.auto': 'Auto-detect', 'hostDetails.distro.mode.auto': 'Auto-detect',
'hostDetails.distro.mode.manual': 'Manual override', 'hostDetails.distro.mode.manual': 'Manual override',

View File

@@ -502,7 +502,52 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.portCredentials': 'Порт и учётные данные', 'hostDetails.section.portCredentials': 'Порт и учётные данные',
'hostDetails.section.appearance': 'Внешний вид', 'hostDetails.section.appearance': 'Внешний вид',
'hostDetails.distro.title': 'Дистрибутив Linux', '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': 'Источник',
'hostDetails.distro.mode.auto': 'Автоопределение', 'hostDetails.distro.mode.auto': 'Автоопределение',
'hostDetails.distro.mode.manual': 'Ручное переопределение', 'hostDetails.distro.mode.manual': 'Ручное переопределение',

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts"; 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 zhCN from "./zh-CN.ts";
import ru from "./ru.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`); 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`);
}
});

View File

@@ -45,7 +45,52 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.portCredentials': '端口与凭据', 'hostDetails.section.portCredentials': '端口与凭据',
'hostDetails.section.appearance': '外观', 'hostDetails.section.appearance': '外观',
'hostDetails.distro.title': 'Linux 发行版', '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': '来源',
'hostDetails.distro.mode.auto': '自动探测', 'hostDetails.distro.mode.auto': '自动探测',
'hostDetails.distro.mode.manual': '手动覆盖', 'hostDetails.distro.mode.manual': '手动覆盖',

View 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\]/);
});

View File

@@ -9,6 +9,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import React, { memo, useCallback, useMemo, useState } from "react"; import React, { memo, useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider"; import { useI18n } from "../application/i18n/I18nProvider";
import { resolveHostIconAppearance } from "../domain/hostIcon";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types"; import { ConnectionLog, Host } from "../types";
import { DistroAvatar } from "./DistroAvatar"; import { DistroAvatar } from "./DistroAvatar";
@@ -67,7 +68,12 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
const { t, resolvedLocale } = useI18n(); const { t, resolvedLocale } = useI18n();
const isLocal = log.protocol === "local" || log.hostname === "localhost"; const isLocal = log.protocol === "local" || log.hostname === "localhost";
const isSerial = log.protocol === "serial"; 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 ( return (
<div <div
@@ -101,6 +107,9 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
os: log.hostOs ?? "linux", os: log.hostOs ?? "linux",
distro: log.hostDistro, distro: log.hostDistro,
distroMode: "auto", distroMode: "auto",
iconMode: log.hostIconMode,
iconId: log.hostIconId,
iconColor: log.hostIconColor,
}} }}
fallback={(log.hostOs ?? "linux")[0].toUpperCase()} fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
size="log" size="log"

View 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\]/);
});

View File

@@ -1,8 +1,10 @@
import { Server, Usb } from "lucide-react"; import { Server, Usb } from "lucide-react";
import React, { memo } from "react"; import React, { memo } from "react";
import { getEffectiveHostDistro } from "../domain/host"; import { getEffectiveHostDistro } from "../domain/host";
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from "../domain/hostIcon";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Host } from "../types"; import { Host } from "../types";
import { renderHostIconGlyph } from "./hostIconRenderer";
export const DISTRO_LOGOS: Record<string, string> = { export const DISTRO_LOGOS: Record<string, string> = {
ubuntu: "/distro/ubuntu.svg", ubuntu: "/distro/ubuntu.svg",
@@ -71,7 +73,7 @@ export const DISTRO_COLORS: Record<string, string> = {
type DistroAvatarProps = { type DistroAvatarProps = {
host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> & host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> &
Partial<Pick<Host, "protocol">>; Partial<Pick<Host, "protocol" | "iconMode" | "iconId" | "iconColor">>;
fallback: string; fallback: string;
className?: string; className?: string;
/** xs matches top tab bar icons (h-4 rounded rect) */ /** 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) { if (logo && !errored) {
return ( return (
<div <div
className={cn( className={cn(
"shrink-0 rounded flex items-center justify-center overflow-hidden", "shrink-0 rounded flex items-center justify-center overflow-hidden",
containerClass, containerClass,
bg, !customColor && bg,
className, className,
)} )}
style={customColor ? { backgroundColor: customColor.colorHex } : undefined}
> >
<img <img
src={logo} src={logo}

View File

@@ -3,6 +3,7 @@ import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRoun
import type { Host } from "../types"; import type { Host } from "../types";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { DistroAvatar } from "./DistroAvatar"; import { DistroAvatar } from "./DistroAvatar";
import { HostIconPicker } from "./HostIconPicker";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox"; import { Combobox } from "./ui/combobox";
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details"; import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
@@ -71,6 +72,28 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</div> </div>
</HostDetailsSection> </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 <HostDetailsSection
icon={<KeyRound size={14} className="text-muted-foreground" />} icon={<KeyRound size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.portCredentials")} title={t("hostDetails.section.portCredentials")}

View File

@@ -296,3 +296,19 @@ test("HostDetailsPanel does not offer to disable telnet when telnet is the prima
assert.ok(telnetHeader); assert.ok(telnetHeader);
assert.doesNotMatch(telnetHeader[0], /hover:text-destructive/); 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/);
});

View 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/);
});

View 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>
);
};

View File

@@ -103,6 +103,15 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
assert.match(topTabsSource, /data-section="top-tabs-quick-switcher-toggle"/); 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", () => { test("workspace session drag data is recognized with a dedicated drag type", () => {
const data = new Map([ const data = new Map([
[WORKSPACE_SESSION_DRAG_TYPE, "session-1"], [WORKSPACE_SESSION_DRAG_TYPE, "session-1"],

View 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} />;
};

View File

@@ -6,6 +6,7 @@ import type { LogView } from '../../application/state/logViewState';
import { useWindowControls } from '../../application/state/useWindowControls'; import { useWindowControls } from '../../application/state/useWindowControls';
import { useI18n } from '../../application/i18n/I18nProvider'; import { useI18n } from '../../application/i18n/I18nProvider';
import { getEffectiveHostDistro } from '../../domain/host'; import { getEffectiveHostDistro } from '../../domain/host';
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from '../../domain/hostIcon';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { Host, TerminalSession, Workspace } from '../../types'; import { Host, TerminalSession, Workspace } from '../../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar'; 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 { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent'; import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
import { renderHostIconGlyph } from '../hostIconRenderer';
// File extensions that render the code-file icon instead of the plain text icon. // 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; 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 // Try distro logo with brand background color
if (host) { if (host) {
const distro = getEffectiveHostDistro(host); const distro = getEffectiveHostDistro(host);
const logo = DISTRO_LOGOS[distro]; const logo = DISTRO_LOGOS[distro];
if (logo) { if (logo) {
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default; const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
const customColor = resolveHostIconColorAppearance(host);
return ( return (
<div className={cn(boxBase, bg)}> <div className={cn(boxBase, !customColor && bg)} style={customColor ? { backgroundColor: customColor.colorHex } : undefined}>
<img <img
src={logo} src={logo}
alt={distro || host.os} alt={distro || host.os}

View File

@@ -23,7 +23,9 @@ const makeHost = (overrides: Partial<Host> = {}): Host => ({
hostname: "127.0.0.1", hostname: "127.0.0.1",
port: 22, port: 22,
username: "root", username: "root",
authType: "password", authMethod: "password",
tags: [],
os: "linux",
createdAt: 1, createdAt: 1,
protocol: "ssh", protocol: "ssh",
...overrides, ...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", () => { test("preserves a concurrent terminal timestamp toggle when host details did not edit it", () => {
const openedHost = makeHost({ showLineTimestamps: false }); const openedHost = makeHost({ showLineTimestamps: false });
const latestHost = makeHost({ showLineTimestamps: true }); const latestHost = makeHost({ showLineTimestamps: true });

View File

@@ -1,4 +1,5 @@
import { Host, TerminalSettings } from './models'; import { Host, TerminalSettings } from './models';
import { sanitizeHostIconFields } from './hostIcon';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts'; import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
export type HostLabelRenameResult = export type HostLabelRenameResult =
@@ -326,6 +327,7 @@ export const sanitizeHost = (host: Host): Host => {
: host.distroMode === 'auto' : host.distroMode === 'auto'
? 'auto' ? 'auto'
: undefined; : undefined;
const cleanHostIcon = sanitizeHostIconFields(host);
const migrated = migrateDeprecatedFontOverride(host); const migrated = migrateDeprecatedFontOverride(host);
const cleanNotes = host.notes?.trim() || undefined; const cleanNotes = host.notes?.trim() || undefined;
return { return {
@@ -334,6 +336,10 @@ export const sanitizeHost = (host: Host): Host => {
distro: cleanDistro, distro: cleanDistro,
distroMode: cleanDistroMode, distroMode: cleanDistroMode,
manualDistro: cleanManualDistro || undefined, manualDistro: cleanManualDistro || undefined,
iconMode: undefined,
iconId: undefined,
iconColor: undefined,
...cleanHostIcon,
notes: cleanNotes, notes: cleanNotes,
}; };
}; };

78
domain/hostIcon.test.ts Normal file
View 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
View 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;
};

View File

@@ -49,6 +49,45 @@ export interface EnvVar {
// Protocol type for connections // Protocol type for connections
export type HostProtocol = 'ssh' | 'telnet' | 'mosh' | 'et' | 'local' | 'serial'; 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 // Serial port configuration
export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space'; export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space';
@@ -131,6 +170,9 @@ export interface Host {
distro?: string; // detected distro id (e.g., ubuntu, debian) distro?: string; // detected distro id (e.g., ubuntu, debian)
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
manualDistro?: string; // manually selected distro id when distroMode='manual' 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 // Multi-protocol support
protocols?: ProtocolConfig[]; // Multiple protocol configurations protocols?: ProtocolConfig[]; // Multiple protocol configurations
telnetPort?: number; // Telnet-specific port (for quick access) telnetPort?: number; // Telnet-specific port (for quick access)

View File

@@ -1,4 +1,6 @@
// Known Hosts - discovered from system SSH known_hosts file // Known Hosts - discovered from system SSH known_hosts file
import type { HostIconColorId, HostIconId, HostIconMode } from './connection';
export interface KnownHost { export interface KnownHost {
id: string; id: string;
hostname: string; // The host pattern from known_hosts hostname: string; // The host pattern from known_hosts
@@ -46,6 +48,9 @@ export interface ConnectionLog {
protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'et' | 'serial'; protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'et' | 'serial';
hostOs?: 'linux' | 'windows' | 'macos'; // Snapshot of the connected host OS for log icons hostOs?: 'linux' | 'windows' | 'macos'; // Snapshot of the connected host OS for log icons
hostDistro?: string; // Snapshot of the connected host distro/vendor icon id 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 startTime: number; // Connection start timestamp
endTime?: number; // Connection end timestamp (undefined if still active) endTime?: number; // Connection end timestamp (undefined if still active)
localUsername: string; // System username of the local user localUsername: string; // System username of the local user