Compare commits

...

8 Commits

Author SHA1 Message Date
bincxz
10ff2cc092 ui: increase unfocused workspace terminal opacity from 0.65 to 0.82
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:44:59 +08:00
bincxz
4124c03b80 fix: maintain scroll position when terminal search bar opens/closes
Re-fit terminal and restore viewport scroll position after search bar
toggle to prevent content jumping. Preserves bottom-stick behavior
and removes toolbar bottom border for cleaner appearance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:39:16 +08:00
bincxz
56a3994a52 fix: prevent tab indicator line color flash during theme switching
Keep top tabs theme vars applied based on focused terminal theme,
not just during sidebar preview. Prevents the color flash when
switching themes or closing the theme sidebar panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:21:14 +08:00
陈大猫
e1e730e439 Merge pull request #584 from binaricat/feat/expand-builtin-themes
feat: add 12 new built-in terminal color themes
2026-03-31 14:15:51 +08:00
bincxz
bb17647954 feat: add 12 new built-in terminal color themes
Add popular terminal themes sourced from official repos and
iTerm2-Color-Schemes:

- GitHub Dark / GitHub Light (primer/github-vscode-theme)
- Ubuntu (classic Ubuntu terminal)
- One Dark Pro (Binaryify/OneDark-Pro)
- Horizon (jolaleye/horizon-theme-vscode)
- Palenight (whizkydee/vscode-palenight-theme)
- Panda (tinkertrain/panda-syntax-vscode)
- Snazzy (sindresorhus/hyper-snazzy)
- Synthwave '84 (robb0wen/synthwave-vscode)
- Vesper (minimal dark theme)
- Kanso Dark / Kanso Light (zen-inspired)

Total built-in themes: 62 → 74

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:51:03 +08:00
bincxz
56a0baebeb ui: use accent color for active tab indicator and remove toolbar border
- Active tab top line uses accent/primary color instead of foreground
- Remove terminal toolbar bottom border to reduce visual clutter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:41:01 +08:00
bincxz
d2a6c67e4e refactor: extract shared ThemeList component for theme selection UI
Unify theme item style across ThemeSelectPanel (host details) and
ThemeSelectModal (settings) with a shared ThemeList component featuring
compact swatch previews, dark/light/custom grouping, and no-rounded
selection highlight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:10:22 +08:00
bincxz
56f70d015d ui: optimize host details and chain panel layout
- SFTP Filename Encoding: inline layout with label and select on same row
- Linux Distribution: extract from Appearance into its own Card with Tux icon
- Chain panel: remove non-functional Add Host button, add search filter for
  available hosts, fix long hostname overflow with truncation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:57:17 +08:00
10 changed files with 632 additions and 336 deletions

View File

@@ -1263,18 +1263,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
<div className="space-y-1">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</div>
</div>
<Select
value={form.sftpEncoding || "auto"}
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
>
<SelectTrigger className="h-8">
<SelectTrigger className="h-8 w-28">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
@@ -1286,6 +1288,111 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
{form.os === "linux" && (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<img src="/distro/linux.svg" alt="Linux" className="h-3.5 w-3.5 opacity-70 dark:invert" />
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
</div>
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
<Select
value={form.distroMode || "auto"}
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
<span className="truncate whitespace-nowrap pr-2 text-left">
{form.distroMode === "manual"
? t("hostDetails.distro.mode.manual")
: t("hostDetails.distro.mode.auto")}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
</SelectContent>
</Select>
</div>
{form.distroMode === "manual" ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
<Select
value={form.manualDistro}
onValueChange={(val) => update("manualDistro", val)}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
{(() => {
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
return selectedOption ? (
<div className="flex min-w-0 items-center gap-2 pr-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
selectedOption.bgClass,
)}
>
{selectedOption.icon ? (
<img
src={selectedOption.icon}
alt={selectedOption.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
</div>
) : (
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
);
})()}
</SelectTrigger>
<SelectContent className="min-w-[14rem]">
{distroOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
option.bgClass,
)}
>
{option.icon ? (
<img
src={option.icon}
alt={option.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="whitespace-nowrap">{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
{effectiveFormDistro
? getDistroOptionLabel(effectiveFormDistro)
: t("hostDetails.distro.unknown")}
</div>
</div>
)}
</div>
</Card>
)}
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
@@ -1294,113 +1401,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</p>
</div>
{form.os === "linux" && (
<div className="space-y-2 rounded-lg border border-border/70 bg-secondary/30 p-3">
<div className="flex items-start gap-2">
<Globe size={14} className="mt-0.5 text-muted-foreground" />
<div className="space-y-0.5">
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
<Select
value={form.distroMode || "auto"}
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
<span className="truncate whitespace-nowrap pr-2 text-left">
{form.distroMode === "manual"
? t("hostDetails.distro.mode.manual")
: t("hostDetails.distro.mode.auto")}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
</SelectContent>
</Select>
</div>
{form.distroMode === "manual" ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
<Select
value={form.manualDistro}
onValueChange={(val) => update("manualDistro", val)}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
{(() => {
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
return selectedOption ? (
<div className="flex min-w-0 items-center gap-2 pr-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
selectedOption.bgClass,
)}
>
{selectedOption.icon ? (
<img
src={selectedOption.icon}
alt={selectedOption.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
</div>
) : (
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
);
})()}
</SelectTrigger>
<SelectContent className="min-w-[14rem]">
{distroOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
option.bgClass,
)}
>
{option.icon ? (
<img
src={option.icon}
alt={option.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="whitespace-nowrap">{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
{effectiveFormDistro
? getDistroOptionLabel(effectiveFormDistro)
: t("hostDetails.distro.unknown")}
</div>
</div>
)}
</div>
</div>
)}
{/* SSH Theme Selection */}
<button
type="button"

View File

@@ -1115,6 +1115,26 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => clearTimeout(timer);
}, [inWorkspace, isVisible]);
// When search bar opens/closes, re-fit terminal and maintain scroll position
useEffect(() => {
const term = termRef.current;
if (!term || !fitAddonRef.current) return;
const buffer = term.buffer.active;
const wasAtBottom = buffer.viewportY >= buffer.baseY;
const prevViewportY = buffer.viewportY;
const timer = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
requestAnimationFrame(() => {
if (wasAtBottom) {
term.scrollToBottom();
} else {
term.scrollToLine(prevViewportY);
}
});
}, 0);
return () => clearTimeout(timer);
}, [isSearchOpen]);
useEffect(() => {
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);
if (shouldAutoFocus) {
@@ -1572,7 +1592,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
style={{
backgroundColor: 'var(--terminal-ui-bg)',
color: 'var(--terminal-ui-fg)',

View File

@@ -1452,17 +1452,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [applyTerminalPreviewVars, themePreview]);
useEffect(() => {
if (activeTopTabsThemeId) {
applyTopTabsPreviewVars(activeTopTabsThemeId);
return;
const themeToApply = activeTopTabsThemeId || (previewTargetSessionId ? focusedThemeId : null);
if (themeToApply) {
applyTopTabsPreviewVars(themeToApply);
} else {
clearTopTabsPreviewVars();
}
clearTopTabsPreviewVars();
}, [activeTopTabsThemeId, applyTopTabsPreviewVars]);
}, [activeTopTabsThemeId, focusedThemeId, previewTargetSessionId, applyTopTabsPreviewVars]);
useEffect(() => {
const panelOpen = activeSidePanelTab === 'theme' && !!previewTargetSessionId;
const shouldKeepPreview =
activeSidePanelTab === 'theme' &&
!!previewTargetSessionId &&
panelOpen &&
!!themePreview.targetSessionId &&
!!themePreview.themeId;
@@ -1473,8 +1474,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
clearTerminalPreviewVars(appliedSessionId);
appliedPreviewSessionRef.current = null;
}
clearTopTabsPreviewVars();
if (themePreview.targetSessionId || themePreview.themeId) {
setThemePreview({ targetSessionId: null, themeId: null });
}

124
components/ThemeList.tsx Normal file
View File

@@ -0,0 +1,124 @@
/**
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
*/
import React, { memo, useMemo } from 'react';
import { Check } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
// Memoized theme item component
export const ThemeItem = memo(({
theme,
isSelected,
onSelect
}: {
theme: TerminalTheme;
isSelected: boolean;
onSelect: (id: string) => void;
}) => (
<button
onClick={() => onSelect(theme.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-all',
isSelected
? 'bg-primary/10'
: 'hover:bg-muted'
)}
>
{/* Color swatch preview */}
<div
className="w-12 h-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
style={{ backgroundColor: theme.colors.background }}
>
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
</div>
{isSelected && (
<Check size={16} className="text-primary flex-shrink-0" />
)}
</button>
));
ThemeItem.displayName = 'ThemeItem';
interface ThemeListProps {
selectedThemeId: string;
onSelect: (themeId: string) => void;
}
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
const { t } = useI18n();
const customThemes = useCustomThemes();
const { darkThemes, lightThemes } = useMemo(() => {
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
return { darkThemes: dark, lightThemes: light };
}, []);
return (
<>
{/* Dark Themes Section */}
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.darkThemes')}
</div>
<div className="space-y-1">
{darkThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={onSelect}
/>
))}
</div>
</div>
{/* Light Themes Section */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.lightThemes')}
</div>
<div className="space-y-1">
{lightThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={onSelect}
/>
))}
</div>
</div>
{/* Custom Themes Section */}
{customThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={onSelect}
/>
))}
</div>
</div>
)}
</>
);
};

View File

@@ -1,13 +1,10 @@
import React, { useMemo, useState } from 'react';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
import React from 'react';
import {
AsidePanel,
AsidePanelContent,
} from './ui/aside-panel';
import { ScrollArea } from './ui/scroll-area';
import { ThemeList } from './ThemeList';
interface ThemeSelectPanelProps {
open: boolean;
@@ -18,40 +15,6 @@ interface ThemeSelectPanelProps {
showBackButton?: boolean;
}
// Mini terminal preview component
const TerminalPreview: React.FC<{ theme: TerminalTheme; isSelected: boolean }> = ({
theme,
isSelected
}) => {
return (
<div
className={cn(
"w-16 h-10 rounded-md overflow-hidden border-2 flex-shrink-0",
isSelected ? "border-primary" : "border-transparent"
)}
style={{ backgroundColor: theme.colors.background }}
>
<div className="p-1 text-[4px] font-mono leading-tight" style={{ color: theme.colors.foreground }}>
<div>
<span style={{ color: theme.colors.green }}>$</span>{' '}
<span style={{ color: theme.colors.cyan }}>ls</span>
</div>
<div className="flex gap-0.5 flex-wrap">
<span style={{ color: theme.colors.blue }}>dir/</span>
<span style={{ color: theme.colors.green }}>file</span>
</div>
<div>
<span style={{ color: theme.colors.green }}>$</span>{' '}
<span
className="inline-block w-1 h-1.5"
style={{ backgroundColor: theme.colors.cursor }}
/>
</div>
</div>
</div>
);
};
const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
open,
selectedThemeId,
@@ -60,51 +23,6 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
onBack,
showBackButton = true,
}) => {
// Reserved for future hover preview feature
const [_hoveredThemeId, setHoveredThemeId] = useState<string | null>(null);
const customThemes = useCustomThemes();
// All themes combined
const allThemes = useMemo(() => {
return [...TERMINAL_THEMES, ...customThemes];
}, [customThemes]);
const renderThemeItem = (theme: TerminalTheme) => {
const isSelected = theme.id === selectedThemeId;
return (
<button
key={theme.id}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left",
isSelected
? "bg-primary/10"
: "hover:bg-secondary/50"
)}
onClick={() => onSelect(theme.id)}
onMouseEnter={() => setHoveredThemeId(theme.id)}
onMouseLeave={() => setHoveredThemeId(null)}
>
<TerminalPreview theme={theme} isSelected={isSelected} />
<div className="flex-1 min-w-0">
<div className={cn(
"text-sm font-medium truncate",
isSelected && "text-primary"
)}>
{theme.name}
</div>
{theme.id === 'netcatty-dark' && (
<div className="text-xs text-muted-foreground">Default</div>
)}
{theme.id === 'netcatty-light' && (
<div className="text-xs text-muted-foreground">Light mode</div>
)}
</div>
</button>
);
};
return (
<AsidePanel
open={open}
@@ -116,8 +34,10 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
<AsidePanelContent className="p-0">
<ScrollArea className="h-full">
<div className="py-2">
{/* All themes in a single list */}
{allThemes.map(renderThemeItem)}
<ThemeList
selectedThemeId={selectedThemeId || ''}
onSelect={onSelect}
/>
</div>
</ScrollArea>
</AsidePanelContent>

View File

@@ -522,7 +522,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{activeTabId === session.id && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
style={{ backgroundColor: 'hsl(var(--primary))' }}
/>
)}
{/* Drop indicator line - before */}
@@ -621,7 +621,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{isActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
style={{ backgroundColor: 'hsl(var(--primary))' }}
/>
)}
{/* Drop indicator line - before */}

View File

@@ -2,14 +2,15 @@
* Host Chain Sub-Panel
* Panel for configuring SSH jump host chain
*/
import { ArrowDown,Plus,X } from 'lucide-react';
import React from 'react';
import { ArrowDown,Plus,Search,X } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { AsidePanel } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
import { ScrollArea } from '../ui/scroll-area';
export interface ChainPanelProps {
@@ -38,6 +39,14 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
onCancel,
}) => {
const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState('');
const filteredHosts = useMemo(() => {
if (!searchQuery.trim()) return availableHostsForChain;
const q = searchQuery.toLowerCase();
return availableHostsForChain.filter(
(host) => host.label.toLowerCase().includes(q) || host.hostname.toLowerCase().includes(q)
);
}, [availableHostsForChain, searchQuery]);
return (
<AsidePanel
open={true}
@@ -52,16 +61,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
}
>
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs text-muted-foreground">
{t('hostDetails.chain.desc', { host: formLabel || formHostname })}
</p>
<Button className="w-full h-10" onClick={() => { }}>
<Plus size={14} className="mr-2" /> {t('hostDetails.chain.addHost')}
</Button>
</Card>
<div className="p-4 space-y-4 w-0 min-w-full">
{/* Chain visualization */}
<div className="space-y-2">
{chainedHosts.map((host, index) => (
@@ -73,7 +73,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
)}
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
<span className="text-sm font-medium flex-1">{host.label || host.hostname}</span>
<span className="text-sm font-medium flex-1 min-w-0 truncate">{host.label || host.hostname}</span>
<Button
variant="ghost"
size="icon"
@@ -110,11 +110,20 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
{availableHostsForChain.length > 0 && (
<Card className="p-3 bg-card border-border/80">
<p className="text-xs font-semibold text-muted-foreground mb-2">{t('hostDetails.chain.availableHosts')}</p>
<div className="relative mb-2">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('common.searchPlaceholder')}
className="h-8 pl-8 text-sm"
/>
</div>
<div className="space-y-1">
{availableHostsForChain.map((host) => (
{filteredHosts.map((host) => (
<button
key={host.id}
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left"
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left overflow-hidden"
onClick={() => onAddHost(host.id)}
>
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />

View File

@@ -3,55 +3,12 @@
* A modal dialog for selecting terminal themes in settings
*/
import React, { memo, useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Check, Palette, X } from 'lucide-react';
import { Palette, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../../application/state/customThemeStore';
import { Button } from '../ui/button';
import { cn } from '../../lib/utils';
// Memoized theme item component to prevent unnecessary re-renders
const ThemeItem = memo(({
theme,
isSelected,
onSelect
}: {
theme: TerminalThemeConfig;
isSelected: boolean;
onSelect: (id: string) => void;
}) => (
<button
onClick={() => onSelect(theme.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
isSelected
? 'bg-primary/15 ring-1 ring-primary'
: 'hover:bg-muted'
)}
>
{/* Color swatch preview */}
<div
className="w-12 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
style={{ backgroundColor: theme.colors.background }}
>
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
</div>
{isSelected && (
<Check size={16} className="text-primary flex-shrink-0" />
)}
</button>
));
ThemeItem.displayName = 'ThemeItem';
import { ThemeList } from '../ThemeList';
interface ThemeSelectModalProps {
open: boolean;
@@ -68,15 +25,6 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
}) => {
const { t } = useI18n();
// Group themes by type
const { darkThemes, lightThemes } = useMemo(() => {
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
return { darkThemes: dark, lightThemes: light };
}, []);
const customThemes = useCustomThemes();
// Handle theme selection - select and close
const handleThemeSelect = useCallback((themeId: string) => {
onSelect(themeId);
@@ -134,58 +82,10 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
{/* Theme List */}
<div className="flex-1 min-h-0 overflow-y-auto p-4">
{/* Dark Themes Section */}
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('settings.terminal.themeModal.darkThemes')}
</div>
<div className="space-y-1">
{darkThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
{/* Light Themes Section */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('settings.terminal.themeModal.lightThemes')}
</div>
<div className="space-y-1">
{lightThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
{/* Custom Themes Section */}
{customThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
)}
<ThemeList
selectedThemeId={selectedThemeId}
onSelect={handleThemeSelect}
/>
</div>
{/* Footer */}

View File

@@ -337,7 +337,7 @@ body {
/* Dim terminal text in unfocused workspace panes (default) */
.workspace-pane:not(:focus-within) .xterm-screen {
opacity: 0.65;
opacity: 0.82;
}
/* Border-style focus indicator (opt-in via data attribute) */
[data-workspace-focus="border"] .workspace-pane:not(:focus-within) .xterm-screen {

View File

@@ -1677,5 +1677,329 @@ export const TERMINAL_THEMES: TerminalTheme[] = [
brightCyan: '#83c092',
brightWhite: '#5c6d64'
}
}
},
{
id: 'github-dark',
name: 'GitHub Dark',
type: 'dark',
colors: {
background: '#0d1117',
foreground: '#e6edf3',
cursor: '#2f81f7',
selection: '#264f78',
black: '#484f58',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ffa198',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#ffffff',
}
},
{
id: 'github-light',
name: 'GitHub Light',
type: 'light',
colors: {
background: '#ffffff',
foreground: '#1f2328',
cursor: '#0969da',
selection: '#add6ff',
black: '#24292f',
red: '#cf222e',
green: '#116329',
yellow: '#4d2d00',
blue: '#0969da',
magenta: '#8250df',
cyan: '#1b7c83',
white: '#6e7781',
brightBlack: '#57606a',
brightRed: '#a40e26',
brightGreen: '#1a7f37',
brightYellow: '#633c01',
brightBlue: '#218bff',
brightMagenta: '#a475f9',
brightCyan: '#3192aa',
brightWhite: '#8c959f',
}
},
{
id: 'ubuntu',
name: 'Ubuntu',
type: 'dark',
colors: {
background: '#300a24',
foreground: '#eeeeec',
cursor: '#bbbbbb',
selection: '#b5d5ff',
black: '#2e3436',
red: '#cc0000',
green: '#4e9a06',
yellow: '#c4a000',
blue: '#3465a4',
magenta: '#75507b',
cyan: '#06989a',
white: '#d3d7cf',
brightBlack: '#555753',
brightRed: '#ef2929',
brightGreen: '#8ae234',
brightYellow: '#fce94f',
brightBlue: '#729fcf',
brightMagenta: '#ad7fa8',
brightCyan: '#34e2e2',
brightWhite: '#eeeeec',
}
},
{
id: 'one-dark-pro',
name: 'One Dark Pro',
type: 'dark',
colors: {
background: '#282c34',
foreground: '#abb2bf',
cursor: '#528bff',
selection: '#3e4452',
black: '#3f4451',
red: '#e05561',
green: '#8cc265',
yellow: '#d18f52',
blue: '#4aa5f0',
magenta: '#c162de',
cyan: '#42b3c2',
white: '#d7dae0',
brightBlack: '#4f5666',
brightRed: '#ff616e',
brightGreen: '#a5e075',
brightYellow: '#f0a45d',
brightBlue: '#4dc4ff',
brightMagenta: '#de73ff',
brightCyan: '#4cd1e0',
brightWhite: '#e6e6e6',
}
},
{
id: 'horizon-dark',
name: 'Horizon',
type: 'dark',
colors: {
background: '#1c1e26',
foreground: '#d5d8da',
cursor: '#6c6f93',
selection: '#6c6f93',
black: '#16161c',
red: '#e95678',
green: '#29d398',
yellow: '#fab795',
blue: '#26bbd9',
magenta: '#ee64ac',
cyan: '#59e1e3',
white: '#d5d8da',
brightBlack: '#6c6f93',
brightRed: '#ec6a88',
brightGreen: '#3fdaa4',
brightYellow: '#fbc3a7',
brightBlue: '#3fc4de',
brightMagenta: '#f075b5',
brightCyan: '#6be4e6',
brightWhite: '#ffffff',
}
},
{
id: 'palenight',
name: 'Palenight',
type: 'dark',
colors: {
background: '#292d3e',
foreground: '#bfc7d5',
cursor: '#ffcc00',
selection: '#7580b8',
black: '#292d3e',
red: '#ff5572',
green: '#a9c77d',
yellow: '#ffcb6b',
blue: '#82aaff',
magenta: '#c792ea',
cyan: '#89ddff',
white: '#d0d0d0',
brightBlack: '#676e95',
brightRed: '#ff5572',
brightGreen: '#c3e88d',
brightYellow: '#ffcb6b',
brightBlue: '#82aaff',
brightMagenta: '#c792ea',
brightCyan: '#89ddff',
brightWhite: '#ffffff',
}
},
{
id: 'panda',
name: 'Panda',
type: 'dark',
colors: {
background: '#292a2b',
foreground: '#e6e6e6',
cursor: '#ff4b82',
selection: '#454647',
black: '#757575',
red: '#ff2c6d',
green: '#19f9d8',
yellow: '#ffb86c',
blue: '#45a9f9',
magenta: '#ff75b5',
cyan: '#b084eb',
white: '#cdcdcd',
brightBlack: '#757575',
brightRed: '#ff2c6d',
brightGreen: '#19f9d8',
brightYellow: '#ffcc95',
brightBlue: '#6fc1ff',
brightMagenta: '#ff9ac1',
brightCyan: '#bcaafe',
brightWhite: '#e6e6e6',
}
},
{
id: 'snazzy',
name: 'Snazzy',
type: 'dark',
colors: {
background: '#1e1f29',
foreground: '#ebece6',
cursor: '#e4e4e4',
selection: '#81aec6',
black: '#000000',
red: '#fc4346',
green: '#50fb7c',
yellow: '#f0fb8c',
blue: '#49baff',
magenta: '#fc4cb4',
cyan: '#8be9fe',
white: '#ededec',
brightBlack: '#555555',
brightRed: '#fc4346',
brightGreen: '#50fb7c',
brightYellow: '#f0fb8c',
brightBlue: '#49baff',
brightMagenta: '#fc4cb4',
brightCyan: '#8be9fe',
brightWhite: '#ededec',
}
},
{
id: 'synthwave-84',
name: "Synthwave '84",
type: 'dark',
colors: {
background: '#262335',
foreground: '#f0eff1',
cursor: '#72f1b8',
selection: '#463465',
black: '#241b30',
red: '#fe4450',
green: '#72f1b8',
yellow: '#fede5d',
blue: '#03edf9',
magenta: '#ff7edb',
cyan: '#03edf9',
white: '#f0eff1',
brightBlack: '#7f7094',
brightRed: '#fe4450',
brightGreen: '#72f1b8',
brightYellow: '#f9f972',
brightBlue: '#aa54f9',
brightMagenta: '#ff7edb',
brightCyan: '#03edf9',
brightWhite: '#f2f2e3',
}
},
{
id: 'vesper',
name: 'Vesper',
type: 'dark',
colors: {
background: '#101010',
foreground: '#ffffff',
cursor: '#acb1ab',
selection: '#988049',
black: '#101010',
red: '#f5a191',
green: '#90b99f',
yellow: '#e6b99d',
blue: '#aca1cf',
magenta: '#e29eca',
cyan: '#ea83a5',
white: '#a0a0a0',
brightBlack: '#7e7e7e',
brightRed: '#ff8080',
brightGreen: '#99ffe4',
brightYellow: '#ffc799',
brightBlue: '#b9aeda',
brightMagenta: '#ecaad6',
brightCyan: '#f591b2',
brightWhite: '#ffffff',
}
},
{
id: 'kanso-dark',
name: 'Kanso Dark',
type: 'dark',
colors: {
background: '#090e13',
foreground: '#c5c9c7',
cursor: '#c5c9c7',
selection: '#393b44',
black: '#0d0c0c',
red: '#c4746e',
green: '#8a9a7b',
yellow: '#c4b28a',
blue: '#8ba4b0',
magenta: '#a292a3',
cyan: '#8ea4a2',
white: '#c8c093',
brightBlack: '#a4a7a4',
brightRed: '#e46876',
brightGreen: '#87a987',
brightYellow: '#e6c384',
brightBlue: '#7fbbb3',
brightMagenta: '#938aa9',
brightCyan: '#7aa89f',
brightWhite: '#c5c9c7',
}
},
{
id: 'kanso-light',
name: 'Kanso Light',
type: 'light',
colors: {
background: '#f2f1ef',
foreground: '#22262d',
cursor: '#22262d',
selection: '#e2e1df',
black: '#22262d',
red: '#c84053',
green: '#6f894e',
yellow: '#77713f',
blue: '#4d699b',
magenta: '#b35b79',
cyan: '#597b75',
white: '#545464',
brightBlack: '#6d6f6e',
brightRed: '#d7474b',
brightGreen: '#6e915f',
brightYellow: '#836f4a',
brightBlue: '#6693bf',
brightMagenta: '#624c83',
brightCyan: '#5e857a',
brightWhite: '#43436c',
}
},
];