Skip to content

Commit d1408b8

Browse files
authored
Merge pull request #688 from binaricat/feat/ui-matched-terminal-themes
feat: add Follow Application Theme for terminal + 14 UI-matched themes
2 parents a1c9f5f + 9ca6856 commit d1408b8

22 files changed

Lines changed: 670 additions & 116 deletions

App.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,12 @@ function App({ settings }: { settings: SettingsState }) {
179179
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
180180

181181
const {
182+
theme,
182183
setTheme,
183184
resolvedTheme,
184185
terminalThemeId,
185186
setTerminalThemeId,
187+
followAppTerminalTheme,
186188
currentTerminalTheme,
187189
terminalFontFamilyId,
188190
setTerminalFontFamilyId,
@@ -328,6 +330,11 @@ function App({ settings }: { settings: SettingsState }) {
328330
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
329331

330332
const resolveTheme = (s: TerminalSession): TerminalTheme => {
333+
// When "Follow Application Theme" is on, the UI-matched terminal
334+
// theme overrides everything — including per-host theme overrides.
335+
// This ensures all terminals match the app chrome regardless of
336+
// individual host settings.
337+
if (followAppTerminalTheme) return currentTerminalTheme;
331338
const host = hostById.get(s.hostId) ?? null;
332339
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
333340
return themeById.get(themeId) || currentTerminalTheme;
@@ -360,7 +367,7 @@ function App({ settings }: { settings: SettingsState }) {
360367
const session = sessionById.get(activeTabId);
361368
if (!session) return null;
362369
return resolveTheme(session);
363-
}, [activeTabId, currentTerminalTheme, hostById, sessionById, themeById, workspaceById]);
370+
}, [activeTabId, currentTerminalTheme, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
364371

365372
useImmersiveMode({
366373
activeTabId,
@@ -1303,10 +1310,24 @@ function App({ settings }: { settings: SettingsState }) {
13031310
}, [protocolSelectHost, handleConnectToHost]);
13041311

13051312
const handleToggleTheme = useCallback(() => {
1306-
// Toggle based on the actual rendered theme so clicking always produces a visible change,
1307-
// even when the stored preference is 'system'.
1313+
if (theme === 'system') {
1314+
toast.info(
1315+
t('topTabs.toggleTheme.systemExitMessage'),
1316+
{
1317+
title: t('topTabs.toggleTheme.systemExitTitle'),
1318+
actionLabel: t('topTabs.toggleTheme.openSettings'),
1319+
onClick: () => {
1320+
void (async () => {
1321+
const opened = await openSettingsWindow();
1322+
if (!opened) toast.error(t('toast.settingsUnavailable'), t('common.settings'));
1323+
})();
1324+
},
1325+
}
1326+
);
1327+
return;
1328+
}
13081329
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
1309-
}, [resolvedTheme, setTheme]);
1330+
}, [openSettingsWindow, resolvedTheme, setTheme, t, theme]);
13101331

13111332
const handleOpenQuickSwitcher = useCallback(() => {
13121333
setIsQuickSwitcherOpen(true);
@@ -1380,6 +1401,7 @@ function App({ settings }: { settings: SettingsState }) {
13801401
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
13811402
<TopTabs
13821403
theme={resolvedTheme}
1404+
followAppTerminalTheme={followAppTerminalTheme}
13831405
hosts={hosts}
13841406
sessions={sessions}
13851407
orphanSessions={orphanSessions}
@@ -1481,6 +1503,7 @@ function App({ settings }: { settings: SettingsState }) {
14811503
knownHosts={knownHosts}
14821504
draggingSessionId={draggingSessionId}
14831505
terminalTheme={currentTerminalTheme}
1506+
followAppTerminalTheme={followAppTerminalTheme}
14841507
terminalSettings={terminalSettings}
14851508
terminalFontFamilyId={terminalFontFamilyId}
14861509
fontSize={terminalFontSize}

application/i18n/locales/en.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ const en: Messages = {
253253
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
254254
'settings.terminal.themeModal.lightThemes': 'Light Themes',
255255
'settings.terminal.theme.selectButton': 'Select Theme',
256+
'settings.terminal.theme.followApp': 'Follow Application Theme',
257+
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
256258
'settings.terminal.section.font': 'Font',
257259
'settings.terminal.section.cursor': 'Cursor',
258260
'settings.terminal.section.keyboard': 'Keyboard',
@@ -1250,6 +1252,11 @@ const en: Messages = {
12501252
'terminal.themeModal.fontWeight': 'Font Weight',
12511253
'terminal.themeModal.livePreview': 'Live Preview',
12521254
'terminal.themeModal.themeType': '{type} theme',
1255+
'terminal.hiddenTheme.title': 'Current hidden theme',
1256+
'terminal.hiddenTheme.desc': 'This theme is hidden from manual picks and will be replaced when you choose another theme.',
1257+
'topTabs.toggleTheme.systemExitTitle': 'System theme is active',
1258+
'topTabs.toggleTheme.systemExitMessage': 'Open Settings to choose a fixed Light or Dark theme.',
1259+
'topTabs.toggleTheme.openSettings': 'Open Settings',
12531260

12541261
// Custom Themes
12551262
'terminal.customTheme.section': 'Custom Themes',

application/i18n/locales/zh-CN.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,11 @@ const zhCN: Messages = {
866866
'terminal.themeModal.fontWeight': '字体粗细',
867867
'terminal.themeModal.livePreview': '实时预览',
868868
'terminal.themeModal.themeType': '{type} 主题',
869+
'terminal.hiddenTheme.title': '当前隐藏主题',
870+
'terminal.hiddenTheme.desc': '这个主题已从手动选择列表中隐藏;当你选择其他可见主题后,它会被替换。',
871+
'topTabs.toggleTheme.systemExitTitle': '当前正在跟随系统主题',
872+
'topTabs.toggleTheme.systemExitMessage': '请到设置里选择固定的浅色或深色主题。',
873+
'topTabs.toggleTheme.openSettings': '打开设置',
869874

870875
// Custom Themes
871876
'terminal.customTheme.section': '自定义主题',
@@ -1279,6 +1284,8 @@ const zhCN: Messages = {
12791284
'settings.terminal.themeModal.darkThemes': '深色主题',
12801285
'settings.terminal.themeModal.lightThemes': '浅色主题',
12811286
'settings.terminal.theme.selectButton': '选择主题',
1287+
'settings.terminal.theme.followApp': '跟随应用主题',
1288+
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
12821289
'settings.terminal.section.font': '字体',
12831290
'settings.terminal.section.cursor': '光标',
12841291
'settings.terminal.section.keyboard': '键盘',

application/state/customThemeStore.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ class CustomThemeStore {
7575
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
7676
// Another window changed custom themes — reload from localStorage
7777
this.loadFromStorage();
78-
this.notify();
7978
}
8079
});
8180
} catch {
@@ -129,6 +128,13 @@ class CustomThemeStore {
129128
this.notify();
130129
this.broadcastChange();
131130
};
131+
132+
replaceThemes = (themes: TerminalTheme[]) => {
133+
this.themes = themes.map((theme) => ({ ...theme, colors: { ...theme.colors }, isCustom: true }));
134+
this.saveToStorage();
135+
this.notify();
136+
this.broadcastChange();
137+
};
132138
}
133139

134140
// Singleton
@@ -172,5 +178,9 @@ export const useCustomThemeActions = () => {
172178
customThemeStore.deleteTheme(id);
173179
}, []);
174180

175-
return { addTheme, updateTheme, deleteTheme };
181+
const replaceThemes = useCallback((themes: TerminalTheme[]) => {
182+
customThemeStore.replaceThemes(themes);
183+
}, []);
184+
185+
return { addTheme, updateTheme, deleteTheme, replaceThemes };
176186
};

application/state/useSettingsState.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
STORAGE_KEY_COLOR,
55
STORAGE_KEY_SYNC,
66
STORAGE_KEY_TERM_THEME,
7+
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
78
STORAGE_KEY_THEME,
89
STORAGE_KEY_TERM_FONT_FAMILY,
910
STORAGE_KEY_TERM_FONT_SIZE,
@@ -37,6 +38,7 @@ import {
3738
} from '../../infrastructure/config/storageKeys';
3839
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
3940
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
41+
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
4042
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
4143
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
4244
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
@@ -195,6 +197,17 @@ export const useSettingsState = () => {
195197
});
196198
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
197199
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
200+
const [followAppTerminalTheme, setFollowAppTerminalThemeState] = useState<boolean>(() => {
201+
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
202+
if (stored !== null) return stored === 'true';
203+
// First time seeing this key. For genuinely fresh installs (no existing
204+
// terminal theme in storage) default ON so the terminal matches the app
205+
// theme out of the box. For upgrades from an older version (existing
206+
// terminal theme present) default OFF to avoid silently overriding the
207+
// user's manual choice.
208+
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
209+
return !isUpgrade;
210+
});
198211
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
199212
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
200213
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
@@ -539,6 +552,10 @@ export const useSettingsState = () => {
539552
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
540553
setTerminalThemeId(value);
541554
}
555+
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
556+
const next = value === true || value === 'true';
557+
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
558+
}
542559
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
543560
setTerminalFontFamilyId(value);
544561
}
@@ -642,7 +659,7 @@ export const useSettingsState = () => {
642659
const settingsSnapshotRef = useRef({
643660
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
644661
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
645-
terminalThemeId, terminalFontFamilyId, terminalFontSize,
662+
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
646663
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
647664
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
648665
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
@@ -651,7 +668,7 @@ export const useSettingsState = () => {
651668
settingsSnapshotRef.current = {
652669
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
653670
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
654-
terminalThemeId, terminalFontFamilyId, terminalFontSize,
671+
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
655672
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
656673
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
657674
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
@@ -732,6 +749,13 @@ export const useSettingsState = () => {
732749
setTerminalThemeId(e.newValue);
733750
}
734751
}
752+
// Sync follow-app-theme toggle from other windows
753+
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
754+
const next = e.newValue === 'true';
755+
if (next !== s.followAppTerminalTheme) {
756+
setFollowAppTerminalThemeState(next);
757+
}
758+
}
735759
// Sync terminal font family from other windows
736760
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
737761
if (e.newValue !== s.terminalFontFamilyId) {
@@ -849,6 +873,12 @@ export const useSettingsState = () => {
849873
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
850874
}, [terminalThemeId, notifySettingsChanged]);
851875

876+
useEffect(() => {
877+
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
878+
if (!persistMountedRef.current) return;
879+
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
880+
}, [followAppTerminalTheme, notifySettingsChanged]);
881+
852882
useEffect(() => {
853883
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
854884
if (!persistMountedRef.current) return;
@@ -1116,12 +1146,21 @@ export const useSettingsState = () => {
11161146
// Subscribe to custom theme changes so editing in-place triggers re-render
11171147
const customThemes = useCustomThemes();
11181148

1119-
const currentTerminalTheme = useMemo(
1120-
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId)
1149+
const currentTerminalTheme = useMemo(() => {
1150+
// When "Follow Application Theme" is enabled, pick the terminal theme
1151+
// whose background matches the active UI theme preset.
1152+
if (followAppTerminalTheme) {
1153+
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
1154+
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
1155+
if (mapped) {
1156+
const found = TERMINAL_THEMES.find(t => t.id === mapped);
1157+
if (found) return found;
1158+
}
1159+
}
1160+
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
11211161
|| customThemes.find(t => t.id === terminalThemeId)
1122-
|| TERMINAL_THEMES[0],
1123-
[terminalThemeId, customThemes]
1124-
);
1162+
|| TERMINAL_THEMES[0];
1163+
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId]);
11251164

11261165
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
11271166
key: K,
@@ -1156,6 +1195,8 @@ export const useSettingsState = () => {
11561195
setUiLanguage,
11571196
terminalThemeId,
11581197
setTerminalThemeId,
1198+
followAppTerminalTheme,
1199+
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
11591200
currentTerminalTheme,
11601201
terminalFontFamilyId,
11611202
setTerminalFontFamilyId,

components/GroupDetailsPanel.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import React, { useCallback, useMemo, useState } from "react";
2222
import { useI18n } from "../application/i18n/I18nProvider";
2323
import { customThemeStore } from "../application/state/customThemeStore";
24+
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
2425
import { cn } from "../lib/utils";
2526
import {
2627
EnvVar,
@@ -61,6 +62,7 @@ interface GroupDetailsPanelProps {
6162
allHosts: Host[];
6263
groups: string[];
6364
terminalThemeId: string;
65+
groupConfigs?: GroupConfig[];
6466
terminalFontSize: number;
6567
onSave: (config: GroupConfig, newName?: string, newParent?: string | null) => void;
6668
onCancel: () => void;
@@ -75,6 +77,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
7577
allHosts,
7678
groups,
7779
terminalThemeId,
80+
groupConfigs = [],
7881
terminalFontSize,
7982
onSave,
8083
onCancel,
@@ -277,7 +280,14 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
277280
}, [groups, groupPath, t]);
278281

279282
// Effective theme
280-
const effectiveThemeId = form.theme || terminalThemeId;
283+
const inheritedThemeId = useMemo(() => {
284+
if (!parentGroup || groupConfigs.length === 0) return terminalThemeId;
285+
return resolveGroupTerminalThemeId(resolveGroupDefaults(parentGroup, groupConfigs), terminalThemeId);
286+
}, [groupConfigs, parentGroup, terminalThemeId]);
287+
const effectiveThemeId = form.themeOverride === false
288+
? inheritedThemeId
289+
: (form.theme || inheritedThemeId);
290+
const hasActiveThemeOverride = form.themeOverride === true || (form.theme != null && form.themeOverride !== false);
281291

282292
// Save handler
283293
const handleSubmit = () => {
@@ -325,7 +335,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
325335
}),
326336
// Shared fields (always saved)
327337
...(form.charset !== undefined && { charset: form.charset }),
328-
...(form.theme !== undefined && { theme: form.theme }),
338+
...((form.themeOverride !== false && form.theme !== undefined) && { theme: form.theme }),
329339
...(form.themeOverride !== undefined && { themeOverride: form.themeOverride }),
330340
...(form.fontFamily !== undefined && { fontFamily: form.fontFamily }),
331341
...(form.fontFamilyOverride !== undefined && { fontFamilyOverride: form.fontFamilyOverride }),
@@ -411,6 +421,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
411421
open={true}
412422
selectedThemeId={effectiveThemeId}
413423
onSelect={(themeId) => {
424+
if (themeId === effectiveThemeId && !hasActiveThemeOverride) {
425+
setActiveSubPanel("none");
426+
return;
427+
}
414428
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
415429
setActiveSubPanel("none");
416430
}}
@@ -1027,7 +1041,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
10271041
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
10281042
</span>
10291043
</button>
1030-
{form.themeOverride && (
1044+
{hasActiveThemeOverride && (
10311045
<Button
10321046
variant="ghost"
10331047
size="sm"

0 commit comments

Comments
 (0)