为主机添加可自定义图标和颜色 (#1504)
This commit is contained in:
@@ -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',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': 'Ручное переопределение',
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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': '手动覆盖',
|
||||||
|
|||||||
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";
|
} 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"
|
||||||
|
|||||||
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 { 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}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
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"/);
|
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"],
|
||||||
|
|||||||
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 { 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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
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
|
// 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user