Compare commits

..

3 Commits

Author SHA1 Message Date
sakuradairong
850d038c5a fix: cap unlimited terminal scrollback
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
2026-06-19 11:05:06 +08:00
Ryanisgood
52bc48f73a 为主机添加可自定义图标和颜色 (#1504) 2026-06-17 23:32:36 +08:00
Ryanisgood
46755465f9 feat: add SFTP current path copy action (#1506) 2026-06-17 23:31:54 +08:00
30 changed files with 1250 additions and 24 deletions

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executeHotkeyActionImpl, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import { executeHotkeyActionImpl, getLogHostVisualSnapshot, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
@@ -169,3 +169,27 @@ test('quick switch hotkey toggles the quick switcher open state', () => {
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
assert.equal(isQuickSwitcherOpen, false);
});
test('connection log host snapshot includes custom host icon fields', () => {
assert.deepEqual(
getLogHostVisualSnapshot({
id: 'host-1',
label: 'Database',
hostname: 'db.example.com',
username: 'root',
tags: [],
os: 'linux',
distro: 'ubuntu',
iconMode: 'custom',
iconId: 'database',
iconColor: 'blue',
}),
{
hostOs: 'linux',
hostDistro: 'ubuntu',
hostIconMode: 'custom',
hostIconId: 'database',
hostIconColor: 'blue',
},
);
});

View File

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

View File

@@ -151,6 +151,9 @@ export const enVaultMessages: Messages = {
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.copyCurrentPath': 'Copy current path',
'sftp.copyCurrentPath.success': 'Current path copied',
'sftp.copyCurrentPath.error': 'Could not copy current path',
'sftp.viewMode.label': 'View mode',
'sftp.viewMode.list': 'List view',
'sftp.viewMode.tree': 'Tree view',
@@ -464,7 +467,52 @@ export const enVaultMessages: Messages = {
'hostDetails.section.portCredentials': 'Port & Credentials',
'hostDetails.section.appearance': 'Appearance',
'hostDetails.distro.title': 'Linux Distribution',
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
'hostDetails.distro.desc': 'Controls the automatic host icon. A custom Host Icon overrides this display.',
'hostDetails.icon.title': 'Host Icon',
'hostDetails.icon.desc': 'Use automatic distro icons with optional color, or choose a built-in icon.',
'hostDetails.icon.mode.auto': 'Automatic',
'hostDetails.icon.mode.custom': 'Custom',
'hostDetails.icon.reset': 'Reset host icon',
'hostDetails.icon.showLibrary': 'Show icon library',
'hostDetails.icon.hideLibrary': 'Hide icon library',
'hostDetails.icon.autoUsesDistro': 'Use Linux Distribution icon and selected color for this host.',
'hostDetails.icon.customOverridesDistro': 'Built-in icon replaces Linux Distribution for this host.',
'hostDetails.icon.option.server': 'Server',
'hostDetails.icon.option.terminal': 'Terminal',
'hostDetails.icon.option.database': 'Database',
'hostDetails.icon.option.cloud': 'Cloud',
'hostDetails.icon.option.router': 'Router',
'hostDetails.icon.option.shield': 'Shield',
'hostDetails.icon.option.code': 'Code',
'hostDetails.icon.option.box': 'Box',
'hostDetails.icon.option.globe': 'Globe',
'hostDetails.icon.option.cpu': 'CPU',
'hostDetails.icon.option.hard-drive': 'Storage',
'hostDetails.icon.option.network': 'Network',
'hostDetails.icon.option.wifi': 'Wireless',
'hostDetails.icon.option.lock': 'Lock',
'hostDetails.icon.option.key': 'Key',
'hostDetails.icon.option.monitor': 'Monitor',
'hostDetails.icon.option.container': 'Container',
'hostDetails.icon.option.activity': 'Activity',
'hostDetails.icon.option.zap': 'Fast',
'hostDetails.icon.option.server-cog': 'Server settings',
'hostDetails.icon.color.blue': 'Blue',
'hostDetails.icon.color.green': 'Green',
'hostDetails.icon.color.red': 'Red',
'hostDetails.icon.color.amber': 'Amber',
'hostDetails.icon.color.purple': 'Purple',
'hostDetails.icon.color.cyan': 'Cyan',
'hostDetails.icon.color.orange': 'Orange',
'hostDetails.icon.color.slate': 'Slate',
'hostDetails.icon.color.violet': 'Violet',
'hostDetails.icon.color.pink': 'Pink',
'hostDetails.icon.color.rose': 'Rose',
'hostDetails.icon.color.lime': 'Lime',
'hostDetails.icon.color.teal': 'Teal',
'hostDetails.icon.color.sky': 'Sky',
'hostDetails.icon.color.indigo': 'Indigo',
'hostDetails.icon.color.zinc': 'Zinc',
'hostDetails.distro.mode': 'Source',
'hostDetails.distro.mode.auto': 'Auto-detect',
'hostDetails.distro.mode.manual': 'Manual override',

View File

@@ -186,6 +186,9 @@ export const ruVaultMessages: Messages = {
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
'sftp.context.download': 'Скачать',
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
'sftp.copyCurrentPath': 'Копировать текущий путь',
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
'sftp.copyCurrentPath.error': 'Не удалось скопировать текущий путь',
'sftp.viewMode.label': 'Режим просмотра',
'sftp.viewMode.list': 'Список',
'sftp.viewMode.tree': 'Дерево',
@@ -499,7 +502,52 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.portCredentials': 'Порт и учётные данные',
'hostDetails.section.appearance': 'Внешний вид',
'hostDetails.distro.title': 'Дистрибутив Linux',
'hostDetails.distro.desc': 'Автоопределение при подключении или ручное переопределение значка дистрибутива.',
'hostDetails.distro.desc': 'Управляет автоматическим значком хоста. Свой значок хоста переопределяет это отображение.',
'hostDetails.icon.title': 'Значок хоста',
'hostDetails.icon.desc': 'Используйте автоматический значок дистрибутива с отдельным цветом или выберите встроенный значок.',
'hostDetails.icon.mode.auto': 'Авто',
'hostDetails.icon.mode.custom': 'Свой',
'hostDetails.icon.reset': 'Сбросить значок',
'hostDetails.icon.showLibrary': 'Показать библиотеку значков',
'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков',
'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.',
'hostDetails.icon.customOverridesDistro': 'Встроенный значок заменяет значок дистрибутива Linux для этого хоста.',
'hostDetails.icon.option.server': 'Сервер',
'hostDetails.icon.option.terminal': 'Терминал',
'hostDetails.icon.option.database': 'База данных',
'hostDetails.icon.option.cloud': 'Облако',
'hostDetails.icon.option.router': 'Маршрутизатор',
'hostDetails.icon.option.shield': 'Защита',
'hostDetails.icon.option.code': 'Код',
'hostDetails.icon.option.box': 'Узел',
'hostDetails.icon.option.globe': 'Глобус',
'hostDetails.icon.option.cpu': 'CPU',
'hostDetails.icon.option.hard-drive': 'Хранилище',
'hostDetails.icon.option.network': 'Сеть',
'hostDetails.icon.option.wifi': 'Wi-Fi',
'hostDetails.icon.option.lock': 'Замок',
'hostDetails.icon.option.key': 'Ключ',
'hostDetails.icon.option.monitor': 'Монитор',
'hostDetails.icon.option.container': 'Контейнер',
'hostDetails.icon.option.activity': 'Активность',
'hostDetails.icon.option.zap': 'Быстрый',
'hostDetails.icon.option.server-cog': 'Настройки сервера',
'hostDetails.icon.color.blue': 'Синий',
'hostDetails.icon.color.green': 'Зеленый',
'hostDetails.icon.color.red': 'Красный',
'hostDetails.icon.color.amber': 'Янтарный',
'hostDetails.icon.color.purple': 'Фиолетовый',
'hostDetails.icon.color.cyan': 'Голубой',
'hostDetails.icon.color.orange': 'Оранжевый',
'hostDetails.icon.color.slate': 'Серый',
'hostDetails.icon.color.violet': 'Фиолетово-синий',
'hostDetails.icon.color.pink': 'Розовый',
'hostDetails.icon.color.rose': 'Розово-красный',
'hostDetails.icon.color.lime': 'Лаймовый',
'hostDetails.icon.color.teal': 'Бирюзовый',
'hostDetails.icon.color.sky': 'Небесный',
'hostDetails.icon.color.indigo': 'Индиго',
'hostDetails.icon.color.zinc': 'Цинковый',
'hostDetails.distro.mode': 'Источник',
'hostDetails.distro.mode.auto': 'Автоопределение',
'hostDetails.distro.mode.manual': 'Ручное переопределение',

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
import zhCN from "./zh-CN.ts";
import ru from "./ru.ts";
@@ -53,3 +54,24 @@ test("localized settings include terminal font weight option labels", () => {
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
}
});
test("localized vault messages include host icon labels", () => {
const keys = [
"hostDetails.icon.title",
"hostDetails.icon.desc",
"hostDetails.icon.mode.auto",
"hostDetails.icon.mode.custom",
"hostDetails.icon.reset",
"hostDetails.icon.showLibrary",
"hostDetails.icon.hideLibrary",
"hostDetails.icon.autoUsesDistro",
"hostDetails.icon.customOverridesDistro",
...HOST_ICON_IDS.map((id) => `hostDetails.icon.option.${id}`),
...HOST_ICON_COLORS.map((color) => `hostDetails.icon.color.${color.id}`),
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing host icon labels`);
}
});

View File

@@ -573,6 +573,9 @@ export const zhCNCoreMessages: Messages = {
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.copyCurrentPath': '复制当前路径',
'sftp.copyCurrentPath.success': '已复制当前路径',
'sftp.copyCurrentPath.error': '无法复制当前路径',
'sftp.viewMode.label': '视图模式',
'sftp.viewMode.list': '列表视图',
'sftp.viewMode.tree': '树形视图',

View File

@@ -45,7 +45,52 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.portCredentials': '端口与凭据',
'hostDetails.section.appearance': '外观',
'hostDetails.distro.title': 'Linux 发行版',
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
'hostDetails.distro.desc': '控制自动主机图标。自定义主机图标会覆盖此显示。',
'hostDetails.icon.title': '主机图标',
'hostDetails.icon.desc': '使用自动发行版图标并可单独改色,或选择内置图标。',
'hostDetails.icon.mode.auto': '自动',
'hostDetails.icon.mode.custom': '自定义',
'hostDetails.icon.reset': '重置主机图标',
'hostDetails.icon.showLibrary': '展开图标库',
'hostDetails.icon.hideLibrary': '收起图标库',
'hostDetails.icon.autoUsesDistro': '使用 Linux 发行版图标和所选颜色显示此主机。',
'hostDetails.icon.customOverridesDistro': '内置图标会替换此主机的 Linux 发行版图标。',
'hostDetails.icon.option.server': '服务器',
'hostDetails.icon.option.terminal': '终端',
'hostDetails.icon.option.database': '数据库',
'hostDetails.icon.option.cloud': '云主机',
'hostDetails.icon.option.router': '路由器',
'hostDetails.icon.option.shield': '安全',
'hostDetails.icon.option.code': '代码',
'hostDetails.icon.option.box': '节点',
'hostDetails.icon.option.globe': '公网',
'hostDetails.icon.option.cpu': '计算',
'hostDetails.icon.option.hard-drive': '存储',
'hostDetails.icon.option.network': '网络',
'hostDetails.icon.option.wifi': '无线',
'hostDetails.icon.option.lock': '锁定',
'hostDetails.icon.option.key': '密钥',
'hostDetails.icon.option.monitor': '显示器',
'hostDetails.icon.option.container': '容器',
'hostDetails.icon.option.activity': '活动',
'hostDetails.icon.option.zap': '高速',
'hostDetails.icon.option.server-cog': '服务器设置',
'hostDetails.icon.color.blue': '蓝色',
'hostDetails.icon.color.green': '绿色',
'hostDetails.icon.color.red': '红色',
'hostDetails.icon.color.amber': '琥珀色',
'hostDetails.icon.color.purple': '紫色',
'hostDetails.icon.color.cyan': '青色',
'hostDetails.icon.color.orange': '橙色',
'hostDetails.icon.color.slate': '石板灰',
'hostDetails.icon.color.violet': '紫罗兰',
'hostDetails.icon.color.pink': '粉色',
'hostDetails.icon.color.rose': '玫瑰红',
'hostDetails.icon.color.lime': '青柠',
'hostDetails.icon.color.teal': '蓝绿色',
'hostDetails.icon.color.sky': '天蓝',
'hostDetails.icon.color.indigo': '靛蓝',
'hostDetails.icon.color.zinc': '锌灰',
'hostDetails.distro.mode': '来源',
'hostDetails.distro.mode.auto': '自动探测',
'hostDetails.distro.mode.manual': '手动覆盖',

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

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

View File

@@ -3,6 +3,7 @@ import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRoun
import type { Host } from "../types";
import { cn } from "../lib/utils";
import { DistroAvatar } from "./DistroAvatar";
import { HostIconPicker } from "./HostIconPicker";
import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox";
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
@@ -71,6 +72,28 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</div>
</HostDetailsSection>
<HostDetailsSection
icon={<DistroAvatar host={form as Host} fallback="H" size="sm" />}
title={t("hostDetails.icon.title")}
hint={t("hostDetails.icon.desc")}
>
<HostIconPicker
iconMode={form.iconMode}
iconId={form.iconId}
iconColor={form.iconColor}
onChange={(next) => {
update("iconMode", next.iconMode);
update("iconId", next.iconId);
update("iconColor", next.iconColor);
}}
onReset={() => {
update("iconMode", undefined);
update("iconId", undefined);
update("iconColor", undefined);
}}
/>
</HostDetailsSection>
<HostDetailsSection
icon={<KeyRound size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.portCredentials")}

View File

@@ -296,3 +296,19 @@ test("HostDetailsPanel does not offer to disable telnet when telnet is the prima
assert.ok(telnetHeader);
assert.doesNotMatch(telnetHeader[0], /hover:text-destructive/);
});
test("HostDetailsPanel shows host icon customization in the connection settings", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
proxyProfileId: undefined,
iconMode: "custom",
iconId: "database",
iconColor: "blue",
});
assert.match(markup, /Host Icon/);
assert.match(markup, /Database/);
assert.match(markup, /Violet/);
assert.match(markup, /Built-in icon replaces Linux Distribution/);
assert.match(markup, /IP or Hostname/);
});

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"/);
});
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
const source = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
assert.match(source, /resolveHostIconAppearance\(host\)/);
assert.ok(
source.indexOf("resolveHostIconAppearance(host)") < source.indexOf("getEffectiveHostDistro(host)"),
"custom host icon should be checked before distro fallback",
);
});
test("workspace session drag data is recognized with a dedicated drag type", () => {
const data = new Map([
[WORKSPACE_SESSION_DRAG_TYPE, "session-1"],

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,8 @@ import { renderToStaticMarkup } from "react-dom/server";
import {
getSftpBookmarkButtonLabelKey,
getNextSftpViewMode,
copySftpCurrentPathToClipboard,
getNextSftpToolbarDisplayPath,
getSftpViewModeToggleTarget,
getSftpViewModeToggleLabelKey,
shouldToggleSftpBookmarkFromButton,
@@ -139,6 +141,137 @@ test("toolbar renders one view-mode toggle instead of separate list and tree but
assert.match(markup, /aria-label="Bookmarked paths"/);
});
test("toolbar exposes copy-current-path action for the active directory", () => {
const pane: SftpPane = {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath: "/var/www/app",
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
};
const markup = renderToStaticMarkup(
React.createElement(SftpPaneToolbar, {
t: (key: string) => ({
"sftp.copyCurrentPath": "Copy current path",
"sftp.viewMode.switchToTree": "Switch to tree view",
"sftp.bookmark.list": "Bookmarked paths",
}[key] ?? key),
pane,
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
startTransition: (callback: () => void) => callback(),
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [],
isCurrentPathBookmarked: false,
onToggleBookmark: () => {},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
viewMode: "list",
onSetViewMode: () => {},
}),
);
assert.match(markup, /aria-label="Copy current path"/);
});
test("copy-current-path action writes the displayed path and reports success", async () => {
let copiedText = "";
let successMessage = "";
await copySftpCurrentPathToClipboard({
currentPath: "/srv/current",
writeText: async (text) => {
copiedText = text;
},
onSuccess: (message) => {
successMessage = message;
},
onError: () => {},
t: (key) => ({
"sftp.copyCurrentPath.success": "Current path copied",
}[key] ?? key),
});
assert.equal(copiedText, "/srv/current");
assert.equal(successMessage, "Current path copied");
});
test("copy-current-path action reports clipboard failures", async () => {
let errorMessage = "";
await copySftpCurrentPathToClipboard({
currentPath: "/srv/current",
writeText: async () => {
throw new Error("denied");
},
onSuccess: () => {},
onError: (message) => {
errorMessage = message;
},
t: (key) => ({
"sftp.copyCurrentPath.error": "Could not copy current path",
}[key] ?? key),
});
assert.equal(errorMessage, "Could not copy current path");
});
test("toolbar display path keeps the previous confirmed path while loading the same connection", () => {
assert.equal(
getNextSftpToolbarDisplayPath({
previousDisplayPath: "/srv/old",
previousConnectionId: "conn-1",
connectionId: "conn-1",
currentPath: "/srv/new",
loading: true,
}),
"/srv/old",
);
});
test("bookmark list renders saved paths as selectable rows", () => {
const markup = renderToStaticMarkup(
React.createElement(

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
@@ -10,6 +10,7 @@ import { SftpBreadcrumb } from "./SftpBreadcrumb";
import type { SftpFilenameEncoding } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { SftpBookmark } from "../../domain/models";
import { toast } from "../ui/toast";
type SftpPaneViewMode = "list" | "tree";
@@ -43,6 +44,46 @@ export const getSftpBookmarkButtonLabelKey = ({
? "sftp.bookmark.add"
: "sftp.bookmark.list";
export const copySftpCurrentPathToClipboard = async ({
currentPath,
writeText,
onSuccess,
onError,
t,
}: {
currentPath: string;
writeText: (text: string) => Promise<void>;
onSuccess: (message: string) => void;
onError: (message: string) => void;
t: (key: string) => string;
}) => {
if (!currentPath) return;
try {
await writeText(currentPath);
onSuccess(t("sftp.copyCurrentPath.success"));
} catch {
onError(t("sftp.copyCurrentPath.error"));
}
};
export const getNextSftpToolbarDisplayPath = ({
previousDisplayPath,
previousConnectionId,
connectionId,
currentPath,
loading,
}: {
previousDisplayPath: string;
previousConnectionId: string | undefined;
connectionId: string | undefined;
currentPath: string | undefined;
loading: boolean;
}): string => {
const connectionChanged = connectionId !== previousConnectionId;
return connectionChanged || !loading ? currentPath ?? "" : previousDisplayPath;
};
interface SftpPaneToolbarProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
@@ -208,12 +249,17 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
useEffect(() => {
const connectionChanged = pane.connection?.id !== prevDisplayConnectionIdRef.current;
const previousConnectionId = prevDisplayConnectionIdRef.current;
prevDisplayConnectionIdRef.current = pane.connection?.id;
// Sync immediately on connection change; otherwise defer until loading completes
if (connectionChanged || !pane.loading) {
setDisplayPath(pane.connection?.currentPath ?? "");
}
setDisplayPath((previousDisplayPath) =>
getNextSftpToolbarDisplayPath({
previousDisplayPath,
previousConnectionId,
connectionId: pane.connection?.id,
currentPath: pane.connection?.currentPath,
loading: pane.loading,
})
);
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
// Observe the overall toolbar width to decide whether to collapse action buttons
@@ -248,6 +294,16 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
}
}, [showFilterBar, setShowFilterBar, filterInputRef]);
const handleCopyCurrentPath = useCallback(async () => {
await copySftpCurrentPathToClipboard({
currentPath: displayPath,
writeText: (text) => navigator.clipboard.writeText(text),
onSuccess: (message) => toast.success(message, "SFTP"),
onError: (message) => toast.error(message, "SFTP"),
t,
});
}, [displayPath, t]);
const isRemote = !pane.connection?.isLocal;
const viewModeToggleTarget = getSftpViewModeToggleTarget(viewMode);
const viewModeToggleLabel = t(viewModeToggleTarget.labelKey);
@@ -297,6 +353,21 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
aria-label={t("sftp.copyCurrentPath")}
disabled={!displayPath}
onClick={handleCopyCurrentPath}
>
<ClipboardCopy size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.copyCurrentPath")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@@ -15,6 +15,7 @@ import { fontStore } from "../../../application/state/fontStore";
import { KeywordHighlighter } from "../keywordHighlight";
import {
XTERM_PERFORMANCE_CONFIG,
resolveXTermScrollback,
type XTermPlatform,
resolveXTermPerformanceConfig,
} from "../../../infrastructure/config/xtermPerformance";
@@ -270,11 +271,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const cursorStyle = settings?.cursorShape ?? "block";
const cursorBlink = settings?.cursorBlink ?? true;
// xterm.js treats scrollback=0 as "no scrollback buffer", which breaks mouse
// wheel scrolling (events become arrow-key sequences). The UI uses 0 to mean
// "no limit", so map it to a large value instead.
const rawScrollback = settings?.scrollback ?? 10000;
const scrollback = rawScrollback === 0 ? 999999 : rawScrollback;
const scrollback = resolveXTermScrollback(rawScrollback);
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
const fontWeightBold = settings?.fontWeightBold ?? 700;

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useRef } from 'react';
import { resolveFontWeightBold } from '../../lib/fontWeightAvailability';
import { resolveXTermScrollback } from '../../infrastructure/config/xtermPerformance';
import { shouldInterceptMouseTrackingContextMenu } from './runtime/middleClickBehavior';
type TerminalEffectsContext = Record<string, any>;
@@ -465,7 +466,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (terminalSettings) {
applyUserCursorPreference(termRef.current, terminalSettings);
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
termRef.current.options.scrollback = resolveXTermScrollback(terminalSettings.scrollback);
termRef.current.options.fontWeight = effectiveFontWeight as
| 100
| 200

View File

@@ -6,6 +6,7 @@ import type { LogView } from '../../application/state/logViewState';
import { useWindowControls } from '../../application/state/useWindowControls';
import { useI18n } from '../../application/i18n/I18nProvider';
import { getEffectiveHostDistro } from '../../domain/host';
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from '../../domain/hostIcon';
import { cn } from '../../lib/utils';
import { Host, TerminalSession, Workspace } from '../../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
@@ -14,6 +15,7 @@ import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/t
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
import { renderHostIconGlyph } from '../hostIconRenderer';
// File extensions that render the code-file icon instead of the plain text icon.
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
@@ -78,14 +80,26 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
);
}
if (host) {
const customAppearance = resolveHostIconAppearance(host);
if (customAppearance) {
return (
<div className={cn(boxBase, "text-white")} style={{ backgroundColor: customAppearance.colorHex }}>
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
</div>
);
}
}
// Try distro logo with brand background color
if (host) {
const distro = getEffectiveHostDistro(host);
const logo = DISTRO_LOGOS[distro];
if (logo) {
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
const customColor = resolveHostIconColorAppearance(host);
return (
<div className={cn(boxBase, bg)}>
<div className={cn(boxBase, !customColor && bg)} style={customColor ? { backgroundColor: customColor.colorHex } : undefined}>
<img
src={logo}
alt={distro || host.os}

View File

@@ -23,7 +23,9 @@ const makeHost = (overrides: Partial<Host> = {}): Host => ({
hostname: "127.0.0.1",
port: 22,
username: "root",
authType: "password",
authMethod: "password",
tags: [],
os: "linux",
createdAt: 1,
protocol: "ssh",
...overrides,
@@ -136,6 +138,41 @@ test("migrateHostsFromLegacyLineTimestamps fills only missing host choices", ()
]);
});
test("sanitizeHost preserves valid custom host icon fields", () => {
const sanitized = sanitizeHost(makeHost({
iconMode: "custom",
iconId: "database",
iconColor: "blue",
}));
assert.equal(sanitized.iconMode, "custom");
assert.equal(sanitized.iconId, "database");
assert.equal(sanitized.iconColor, "blue");
});
test("sanitizeHost preserves automatic host icon color fields", () => {
const sanitized = sanitizeHost(makeHost({
iconMode: "auto",
iconColor: "violet",
}));
assert.equal(sanitized.iconMode, "auto");
assert.equal(sanitized.iconId, undefined);
assert.equal(sanitized.iconColor, "violet");
});
test("sanitizeHost removes invalid custom host icon fields", () => {
const sanitized = sanitizeHost(makeHost({
iconMode: "custom",
iconId: "bad",
iconColor: "blue",
} as unknown as Partial<Host>));
assert.equal(sanitized.iconMode, undefined);
assert.equal(sanitized.iconId, undefined);
assert.equal(sanitized.iconColor, undefined);
});
test("preserves a concurrent terminal timestamp toggle when host details did not edit it", () => {
const openedHost = makeHost({ showLineTimestamps: false });
const latestHost = makeHost({ showLineTimestamps: true });

View File

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

78
domain/hostIcon.test.ts Normal file
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
export type HostProtocol = 'ssh' | 'telnet' | 'mosh' | 'et' | 'local' | 'serial';
export type HostIconMode = 'auto' | 'custom';
export type HostIconId =
| 'server'
| 'terminal'
| 'database'
| 'cloud'
| 'router'
| 'shield'
| 'code'
| 'box'
| 'globe'
| 'cpu'
| 'hard-drive'
| 'network'
| 'wifi'
| 'lock'
| 'key'
| 'monitor'
| 'container'
| 'activity'
| 'zap'
| 'server-cog';
export type HostIconColorId =
| 'blue'
| 'green'
| 'red'
| 'amber'
| 'purple'
| 'cyan'
| 'orange'
| 'slate'
| 'violet'
| 'pink'
| 'rose'
| 'lime'
| 'teal'
| 'sky'
| 'indigo'
| 'zinc';
// Serial port configuration
export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space';
@@ -131,6 +170,9 @@ export interface Host {
distro?: string; // detected distro id (e.g., ubuntu, debian)
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
manualDistro?: string; // manually selected distro id when distroMode='manual'
iconMode?: HostIconMode; // Optional host icon mode. Missing/auto preserves distro detection.
iconId?: HostIconId; // Curated icon override used when iconMode='custom'
iconColor?: HostIconColorId; // Palette color used with automatic or custom host icons
// Multi-protocol support
protocols?: ProtocolConfig[]; // Multiple protocol configurations
telnetPort?: number; // Telnet-specific port (for quick access)

View File

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

View File

@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
XTERM_UNLIMITED_SCROLLBACK_CAP,
resolveXTermScrollback,
} from './xtermPerformance';
test('resolveXTermScrollback maps the unlimited sentinel to a 50000 row cap', () => {
assert.equal(XTERM_UNLIMITED_SCROLLBACK_CAP, 50000);
assert.equal(resolveXTermScrollback(0), 50000);
});
test('resolveXTermScrollback preserves explicit positive scrollback values', () => {
assert.equal(resolveXTermScrollback(10000), 10000);
assert.equal(resolveXTermScrollback(50000), 50000);
});

View File

@@ -8,6 +8,14 @@
* - Memory pressure handling
*/
export const XTERM_UNLIMITED_SCROLLBACK_CAP = 50000;
export function resolveXTermScrollback(scrollback: number): number {
// xterm.js treats 0 as "no scrollback". Keep the app's 0 sentinel useful
// without asking xterm to resize/reflow nearly one million buffer rows.
return scrollback === 0 ? XTERM_UNLIMITED_SCROLLBACK_CAP : scrollback;
}
export const XTERM_PERFORMANCE_CONFIG = {
// Memory and Scrollback Settings
scrollback: {