Merge verified main #4010 #4013 #4016 into plugin foundation landing

# Conflicts:
#	index.css
This commit is contained in:
陈奇 committed 2026-10-27 12:29:25 +00:00
commit db1223d161
37 files changed
+969 -45

No files matched your search

+2
View File
@@ -582,6 +582,8 @@ Highlight the focused split pane:
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
'settings.appearance.windowBackgroundBlur': 'Frosted glass background',
'settings.appearance.windowBackgroundBlur.desc': 'Blur the desktop behind the window. At 100% opacity the effect is hidden, but your choice is preserved.',
'settings.appearance.appIcon': 'App Icon',
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
'settings.appearance.appIcon.original': 'Original',
+2
View File
@@ -582,6 +582,8 @@ Resalta el panel dividido enfocado:
'settings.appearance.uiFont.desc': 'Elige la fuente para la interfaz de la aplicación',
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
'settings.appearance.windowBackgroundBlur': 'Fondo de cristal esmerilado',
'settings.appearance.windowBackgroundBlur.desc': 'Desenfoca el escritorio detrás de la ventana. Con una opacidad del 100 %, el efecto no es visible, pero se conserva tu elección.',
'settings.appearance.appIcon': 'Ícono de la aplicación',
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
'settings.appearance.appIcon.original': 'Original',
+2
View File
@@ -525,6 +525,8 @@ export const ruCoreMessages: Messages = {
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
'settings.appearance.windowBackgroundBlur': 'Матовое стекло',
'settings.appearance.windowBackgroundBlur.desc': 'Размывает рабочий стол за окном. При непрозрачности 100 % эффект не виден, но ваш выбор сохраняется.',
'settings.appearance.appIcon': 'Значок приложения',
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
'settings.appearance.appIcon.original': 'Оригинал',
+2
View File
@@ -586,6 +586,8 @@ Vaults 标签保持扁平,不使用这块背景。
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊窗口后方的桌面。100% 不透明时效果不可见,但会保留你的开关设置。',
'settings.appearance.appIcon': '应用图标',
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
'settings.appearance.appIcon.original': '原始',
+2
View File
@@ -588,6 +588,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
'settings.appearance.windowOpacity': '視窗透明度',
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊視窗後方的桌面。100% 不透明時效果不可見,但會保留你的開關設定。',
'settings.appearance.appIcon': '應用程式圖示',
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
'settings.appearance.appIcon.original': '原始',
+7
View File
@@ -49,6 +49,7 @@ import {
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
@@ -99,6 +100,7 @@ interface UseSettingsIpcSyncParams {
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
@@ -148,6 +150,7 @@ export function useSettingsIpcSync({
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
@@ -279,6 +282,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_WINDOW_OPACITY) {
setWindowOpacity(value);
}
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && typeof value === 'boolean') {
setWindowBackgroundBlur((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
const nextVariant = resolveAppIconVariant(value);
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
@@ -371,6 +377,7 @@ export function useSettingsIpcSync({
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setHotkeyScheme,
setIsHotkeyRecordingState,
+14 -4
View File
@@ -53,6 +53,7 @@ import {
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
@@ -116,6 +117,7 @@ interface UseSettingsStorageSyncParams {
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>;
@@ -166,6 +168,7 @@ interface UseSettingsStorageSyncParams {
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
@@ -184,7 +187,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
@@ -193,7 +196,7 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
@@ -208,7 +211,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
@@ -218,7 +221,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -523,6 +526,12 @@ export function useSettingsStorageSync({
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
setWindowOpacity(e.newValue);
}
if (e.key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.windowBackgroundBlur) {
setWindowBackgroundBlur(newValue);
}
}
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
const newValue = resolveAppIconVariant(e.newValue);
if (newValue !== s.appIconVariant) {
@@ -566,6 +575,7 @@ export function useSettingsStorageSync({
setGlobalHotkeyEnabled,
setHostClickBehaviorState,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setHotkeyScheme,
setLightUiThemeId,
@@ -7,6 +7,7 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
} from '../../infrastructure/config/storageKeys';
@@ -17,6 +18,8 @@ import {
} from '../../domain/httpNetworkProxy';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { isMacPlatform } from '../../lib/utils';
import { isWindowBackgroundBlurActive } from '../../domain/windowBackgroundBlur';
import {
parseWindowOpacityRecord,
serializeWindowOpacityRecord,
@@ -47,6 +50,7 @@ interface UseSystemSettingsEffectsParams {
autoLaunchEnabled: boolean;
windowOpacityRecord: WindowOpacityRecord;
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant;
autoUpdateEnabled: boolean;
httpNetworkProxy: HttpNetworkProxySettings;
@@ -68,6 +72,7 @@ export function useSystemSettingsEffects({
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
windowBackgroundBlur,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
@@ -300,6 +305,49 @@ export function useSystemSettingsEffects({
persistMountedRef,
]);
// Window chrome also applies in detached sessions, independently of the
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
// Only the windows that own settings persistence drive the shared native
// state; detached/editor windows consume it (see applySharedWindowSetting).
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeBoolean(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
}, [
enabled,
windowBackgroundBlur,
notifySettingsChanged,
persistMountedRef,
]);
const windowBackgroundBlurActive = isMacPlatform() && isWindowBackgroundBlurActive(
windowBackgroundBlur,
windowOpacityRecord.opacity,
);
// Applies in every app-content window, including detached sessions: the
// transparent backing only looks right when the renderer class matches the
// native vibrancy the main process put on that window.
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('window-background-blur', windowBackgroundBlurActive);
document.documentElement.style.setProperty('--window-content-opacity', String(windowOpacityRecord.opacity));
return () => {
document.documentElement.classList.remove('window-background-blur');
document.documentElement.style.removeProperty('--window-content-opacity');
};
}, [windowBackgroundBlurActive, windowOpacityRecord.opacity]);
// setWindowBackgroundBlur is a main-process handler that retargets every
// app-content window, so a single owner must drive it.
useEffect(() => {
if (!enabled) return;
netcattyBridge.get()?.setWindowBackgroundBlur?.(windowBackgroundBlur).catch((err) => {
console.warn('[WindowBackgroundBlur] Failed to apply macOS vibrancy:', err);
});
}, [enabled, windowBackgroundBlur]);
// Persist and sync app icon variant
useEffect(() => {
if (!enabled) return;
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
import { STORAGE_KEY_WINDOW_BACKGROUND_BLUR } from '../../infrastructure/config/storageKeys';
import { DEFAULT_HTTP_NETWORK_PROXY } from '../../domain/httpNetworkProxy';
import { useSystemSettingsEffects } from './systemSettingsEffects';
type Harness = {
dom: JSDOM;
params: Parameters<typeof useSystemSettingsEffects>[0];
bridgeRequests: boolean[];
blurNotifications: unknown[];
restore: () => void;
};
function setUp(enabled: boolean, windowBackgroundBlur: boolean): Harness {
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: 'http://localhost' });
Object.defineProperty(dom.window.navigator, 'platform', { value: 'MacIntel' });
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const replacements = {
window: dom.window,
document: dom.window.document,
navigator: dom.window.navigator,
localStorage: dom.window.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
};
for (const [key, value] of Object.entries(replacements)) {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { value, configurable: true });
}
const bridgeRequests: boolean[] = [];
const blurNotifications: unknown[] = [];
Object.assign(dom.window, {
netcatty: {
setWindowBackgroundBlur: async (value: boolean) => { bridgeRequests.push(value); },
},
});
const noop = () => {};
const params: Parameters<typeof useSystemSettingsEffects>[0] = {
enabled,
toggleWindowHotkey: '',
globalHotkeyEnabled: false,
closeToTray: false,
showTrayIcon: false,
autoLaunchEnabled: false,
windowOpacityRecord: { opacity: 0.7, preferredOpacity: 0.7, version: 1 },
windowOpacityMutationSourceRef: { current: 'local' },
windowBackgroundBlur,
appIconVariant: 'original',
autoUpdateEnabled: false,
httpNetworkProxy: DEFAULT_HTTP_NETWORK_PROXY,
persistMountedRef: { current: true },
setHotkeyRegistrationError: noop,
setAutoUpdateEnabled: noop,
setAppIconVariant: noop,
setAutoLaunchEnabled: noop,
setAutoLaunchSupported: noop,
notifySettingsChanged: (key, value) => {
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) blurNotifications.push(value);
},
};
return {
dom,
params,
bridgeRequests,
blurNotifications,
restore: () => {
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.window.close();
},
};
}
const Probe = (props: Parameters<typeof useSystemSettingsEffects>[0]) => {
useSystemSettingsEffects(props);
return null;
};
test('opacity changes and remounts preserve the chosen blur preference', async () => {
const harness = setUp(true, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
const opaqueParams = { ...params, windowOpacityRecord: { ...params.windowOpacityRecord, opacity: 1, version: 2 } };
await act(async () => { renderer!.update(React.createElement(Probe, opaqueParams)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
assert.deepEqual(bridgeRequests, [true]);
assert.deepEqual(blurNotifications, [true]);
await act(async () => { renderer!.unmount(); });
await act(async () => { renderer = create(React.createElement(Probe, opaqueParams)); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
await act(async () => { renderer!.update(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
await act(async () => { renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false })); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'false');
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.deepEqual(bridgeRequests, [true, true, false]);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test('detached session windows only apply the blur class, never own the setting', async () => {
// Peer session windows run with enableSystemEffects off. They must still
// paint the transparent backing, but persistence, cross-window broadcast and
// the (all-windows) native vibrancy IPC stay with the settings owners.
const harness = setUp(false, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
assert.equal(document.documentElement.style.getPropertyValue('--window-content-opacity'), '0.7');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
await act(async () => {
renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false }));
});
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
+18 -4
View File
@@ -74,6 +74,7 @@ import {
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
@@ -645,6 +646,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
windowOpacityMutationSourceRef.current = 'local';
setWindowOpacityRecord(toggleWindowOpacityRecord);
}, []);
const [windowBackgroundBlur, setWindowBackgroundBlur] = useState<boolean>(() => {
return readStoredString(STORAGE_KEY_WINDOW_BACKGROUND_BLUR) === 'true';
});
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
const stored = readStoredString(STORAGE_KEY_APP_ICON_VARIANT);
return resolveAppIconVariant(stored ?? DEFAULT_APP_ICON_VARIANT);
@@ -1276,6 +1280,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity: applyIncomingWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
@@ -1298,12 +1303,18 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshTransportIdleTtlMsState,
});
// Detached sessions share opacity and the shortcut configuration needed to
// operate it. Keep general settings listeners and OS effects disabled there.
// Detached sessions share window chrome (opacity, background blur) and the
// shortcut configuration needed to operate it. Keep general settings
// listeners and OS effects disabled there.
useEffect(() => {
if (enableSettingsSync) return;
const applySharedWindowSetting = (key: string, value: unknown) => {
if (key === STORAGE_KEY_WINDOW_OPACITY) applyIncomingWindowOpacity(value);
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) {
// Storage events deliver the raw string; IPC delivers a boolean.
const incoming = typeof value === 'boolean' ? value : value === 'true';
setWindowBackgroundBlur(incoming);
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -1377,7 +1388,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -1387,7 +1398,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -1896,6 +1907,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
windowBackgroundBlur,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
@@ -2276,6 +2288,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
windowOpacity,
setWindowOpacity,
toggleWindowOpacity,
windowBackgroundBlur,
setWindowBackgroundBlur,
appIconVariant,
setAppIconVariant,
rehydrateAllFromStorage,
+2
View File
@@ -576,6 +576,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
windowBackgroundBlur={settings.windowBackgroundBlur}
setWindowBackgroundBlur={settings.setWindowBackgroundBlur}
appIconVariant={settings.appIconVariant}
setAppIconVariant={settings.setAppIconVariant}
/>
+6 -3
View File
@@ -64,6 +64,8 @@ interface TextEditorModalProps {
onClose: () => void;
fileName: string;
initialContent: string;
/** Full path for language detection; the title still uses fileName. */
filePath?: string;
onSave: (content: string) => Promise<void>;
editorWordWrap: boolean;
onToggleWordWrap: () => void;
@@ -79,6 +81,7 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
fileName,
filePath,
initialContent,
onSave,
editorWordWrap,
@@ -94,7 +97,7 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
const [baselineContent, setBaselineContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const [languageId, setLanguageId] = useState(() => getLanguageId(filePath ?? fileName));
const contentRef = useRef(initialContent);
const baselineContentRef = useRef(initialContent);
const savingRef = useRef(false);
@@ -149,13 +152,13 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
setBaselineContent(initialContent);
setSaveError(null);
setSaving(false);
setLanguageId(getLanguageId(fileName));
setLanguageId(getLanguageId(filePath ?? fileName));
contentRef.current = initialContent;
baselineContentRef.current = initialContent;
savingRef.current = false;
closePromptRef.current = null;
viewStateRef.current = null;
}, [initialContent, fileName]);
}, [initialContent, fileName, filePath]);
const saveContent = useCallback(async (contentToSave = contentRef.current): Promise<boolean> => {
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
@@ -8,6 +8,7 @@ import {
getTextEditorContentStats,
isTextEditorCommandWEnabled,
isTextEditorReadOnly,
languageIdToMonaco,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
@@ -16,6 +17,11 @@ import { DEFAULT_KEY_BINDINGS } from "../../domain/models/keyBindings.ts";
const wrap = (child: React.ReactElement) =>
React.createElement(TooltipProvider, null, child);
test("maps manual Nginx selection to the registered language", () => {
assert.equal(languageIdToMonaco('nginx'), 'nginx');
assert.equal(languageIdToMonaco('ini'), 'ini');
});
test("disables promoting a modal editor to a tab while a save is running", () => {
assert.equal(canPromoteTextEditor({ saving: true }), false);
assert.equal(canPromoteTextEditor({ saving: false }), true);
+4 -1
View File
@@ -30,6 +30,7 @@ import { useClipboardBackend } from '../../application/state/useClipboardBackend
import { isPrimaryModifierWBinding } from '../../application/state/windowCommandClose';
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
import { pasteForMonacoEditorCommand } from '../../infrastructure/monaco/monacoClipboardPaste';
import { registerNginxLanguage } from '../../infrastructure/monaco/nginxLanguage';
import { useNetcattyMonacoTheme } from '../../infrastructure/monaco/useNetcattyMonacoTheme';
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
import { Button } from '../ui/button';
@@ -37,7 +38,7 @@ import { Combobox } from '../ui/combobox';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Map our language IDs to Monaco language IDs
const languageIdToMonaco = (langId: string): string => {
export const languageIdToMonaco = (langId: string): string => {
const mapping: Record<string, string> = {
'javascript': 'javascript',
'typescript': 'typescript',
@@ -73,6 +74,7 @@ const languageIdToMonaco = (langId: string): string => {
'yaml': 'yaml',
'toml': 'ini',
'ini': 'ini',
'nginx': 'nginx',
'sql': 'sql',
'graphql': 'graphql',
'markdown': 'markdown',
@@ -512,6 +514,7 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
language={monacoLanguage}
value={content}
onChange={handleEditorChange}
beforeMount={registerNginxLanguage}
onMount={handleEditorMount}
theme={customThemeName}
loading={
@@ -23,7 +23,7 @@ import {
import { resolveAppIconVariant, type AppIconVariant } from "../../../domain/appIconVariant";
import { resolveNoteFontSelectionFamily, resolveNoteFontSelectionId } from "../../../domain/noteFonts";
import { DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../domain/systemKnownHostsAutoImport";
import { cn } from "../../../lib/utils";
import { cn, isMacPlatform } from "../../../lib/utils";
import { SectionHeader, SettingsAnchor, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
import { TerminalFontSelect } from "../TerminalFontSelect";
@@ -69,6 +69,8 @@ function SettingsAppearanceTab(props: {
setShowHostTreeSidebar: (enabled: boolean) => void;
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
windowBackgroundBlur: boolean;
setWindowBackgroundBlur: (enabled: boolean) => void;
appIconVariant: AppIconVariant;
setAppIconVariant: (variant: AppIconVariant) => void;
}) {
@@ -140,10 +142,13 @@ function SettingsAppearanceTab(props: {
setShowHostTreeSidebar,
windowOpacity,
setWindowOpacity,
windowBackgroundBlur,
setWindowBackgroundBlur,
appIconVariant,
setAppIconVariant,
} = props;
const resolvedAppIconVariant = resolveAppIconVariant(appIconVariant);
const isMac = isMacPlatform();
const WINDOW_OPACITY_PRESETS = [
{ label: '100%', value: 1 },
@@ -315,6 +320,19 @@ function SettingsAppearanceTab(props: {
</div>
</div>
</SettingRow>
{isMac && (
<SettingRow
anchorId="appearance-window-background-blur"
label={t("settings.appearance.windowBackgroundBlur")}
description={t("settings.appearance.windowBackgroundBlur.desc")}
>
<Toggle
checked={windowBackgroundBlur}
onChange={setWindowBackgroundBlur}
ariaLabel={t("settings.appearance.windowBackgroundBlur")}
/>
</SettingRow>
)}
</div>
<SectionHeader title={t("settings.appearance.uiTheme")} />
+1
View File
@@ -264,6 +264,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
open={showTextEditor}
onClose={closeTextEditor}
fileName={textEditorFileName}
filePath={textEditorTarget?.fullPath}
initialContent={textEditorContent}
onSave={handleSaveTextFile}
editorWordWrap={editorWordWrap}
+2 -2
View File
@@ -248,7 +248,7 @@ export const useSftpViewFileOps = ({
hostId: target.hostId,
remotePath: target.fullPath,
fileName: target.file.name,
languageId: snapshot.languageId || getLanguageId(target.file.name),
languageId: snapshot.languageId || getLanguageId(target.fullPath),
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
@@ -284,7 +284,7 @@ export const useSftpViewFileOps = ({
hostId: target.hostId,
remotePath: target.fullPath,
fileName: target.file.name,
languageId: snapshot.languageId || getLanguageId(target.file.name),
languageId: snapshot.languageId || getLanguageId(target.fullPath),
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
+131
View File
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import ts from 'typescript';
import type { TextEditorPaneProps } from '../editor/TextEditorPane';
import type { SftpStateApi } from '../../application/state/useSftpState';
import { editorTabStore } from '../../application/state/editorTabStore';
import { activeTabStore } from '../../application/state/activeTabStore';
import { useSftpViewFileOps } from './hooks/useSftpViewFileOps';
// Keep the real modal state/save flow; replace only Monaco and visual chrome.
function loadModal(pane: React.ComponentType<TextEditorPaneProps>) {
const url = new URL('../TextEditorModal.tsx', import.meta.url);
const compiled = ts.transpileModule(readFileSync(url, 'utf8'), { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true,
} }).outputText;
const require = createRequire(url);
const passthrough = ({ children }: { children: React.ReactNode }) => children;
const replacements: Record<string, unknown> = {
'./editor/TextEditorPane': { TextEditorPane: pane },
'./ui/dialog': { Dialog: passthrough, DialogContent: passthrough, DialogTitle: passthrough },
'./ui/toast': { toast: { success() {}, error() {} } },
'../application/state/windowInputFocus': { scheduleWindowInputFocus() {} },
};
const module = { exports: {} as { default: React.ComponentType<Record<string, unknown>> } };
new Function('require', 'module', 'exports', compiled)(
(id: string) => replacements[id] ?? require(id), module, module.exports,
);
return module.exports.default;
}
test('SFTP modal uses full paths, preserves manual selection and saves exact content', async () => {
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousActiveTab = activeTabStore.getActiveTabId();
const detached: Array<{ languageId: string; content: string; remotePath: string }> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
setTimeout, clearTimeout,
netcatty: { openEditorWindow: async (snapshot: typeof detached[number]) => {
detached.push(snapshot);
return { success: true };
} },
} });
globals.IS_REACT_ACT_ENVIRONMENT = true;
let paneProps: TextEditorPaneProps | undefined;
const Modal = loadModal((props) => { paneProps = props; return null; });
let ops: ReturnType<typeof useSftpViewFileOps> | undefined;
const original = '# fixture\r\nserver { proxy_pass http://$host; }\r\n';
const writes: unknown[][] = [];
const connection = { id: 'nginx-fixture', hostId: 'nginx-host', currentPath: '/etc/nginx/conf.d' };
const sftpRef = { current: {
leftPane: { id: 'nginx-pane', connection }, rightPane: { id: 'nginx-pane', connection },
joinPath: (parent: string, name: string) => `${parent}/${name}`,
readTextFile: async () => original,
writeTextFile: async (...args: unknown[]) => { writes.push(args); },
} as unknown as SftpStateApi };
function Probe() {
ops = useSftpViewFileOps({
sftpRef, behaviorRef: { current: '' }, autoSyncRef: { current: false },
getOpenerForFileRef: { current: () => null }, setOpenerForExtension() {}, t: (key) => key,
});
return ops.showTextEditor ? React.createElement(Modal, {
open: true, onClose() {}, fileName: ops.textEditorTarget!.file.name,
filePath: ops.textEditorTarget!.fullPath, initialContent: ops.textEditorContent,
onSave: ops.handleSaveTextFile, editorWordWrap: false, onToggleWordWrap() {},
hotkeyScheme: 'pc', keyBindings: [], onPromoteToTab: ops.onPromoteToTab,
}) : null;
}
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
const file = { name: 'site.conf', size: original.length, type: 'file' as const, lastModified: 0, sizeFormatted: '', lastModifiedFormatted: '' };
await act(async () => { await ops!.onEditFileLeft(file); });
assert.equal(paneProps!.languageId, 'nginx');
assert.equal(paneProps!.fileName, 'site.conf');
// Keep the modal mounted: basename and content stay identical, only path changes.
await act(async () => { await ops!.onEditFileLeft(file, '/etc/other/site.conf'); });
assert.equal(paneProps!.languageId, 'ini');
await act(async () => { await ops!.onEditFileLeft(file, '/etc/nginx/conf.d/site.conf'); });
assert.equal(paneProps!.languageId, 'nginx');
await act(async () => { paneProps!.onLanguageChange('ini'); });
await act(async () => { paneProps!.onSave(); });
assert.deepEqual(writes, [['left', '/etc/nginx/conf.d/site.conf', original]]);
await act(async () => { paneProps!.onPromoteToTab!(); });
let tab = editorTabStore.getTabs().find(({ sessionId }) => sessionId === connection.id);
assert.equal(tab?.languageId, 'ini', 'promotion preserves a manual override');
assert.equal(tab?.content, original);
editorTabStore.close(tab!.id);
// A generic config can still be switched to Nginx manually.
await act(async () => { await ops!.onEditFileLeft(file, '/etc/other/site.conf'); });
assert.equal(paneProps!.languageId, 'ini');
await act(async () => { paneProps!.onLanguageChange('nginx'); });
assert.equal(paneProps!.languageId, 'nginx', 'arbitrary config supports manual Nginx selection');
await act(async () => { paneProps!.onPromoteToTab!(); });
tab = editorTabStore.getTabs().find(({ sessionId }) => sessionId === connection.id);
assert.equal(tab?.languageId, 'nginx');
// Legacy snapshots without language still infer from the full target path.
await act(async () => { await ops!.onEditFileLeft(file, '/etc/nginx/conf.d/fallback.conf'); });
await act(async () => { ops!.onPromoteToTab({ fileName: file.name, content: original,
baselineContent: original, languageId: '', wordWrap: false, viewState: null }); });
assert.equal(editorTabStore.getTabs().find(({ remotePath }) => remotePath.endsWith('/fallback.conf'))?.languageId, 'nginx');
await act(async () => { await ops!.onEditFileLeft(file, '/etc/nginx/sites-enabled/example.com'); });
await act(async () => { ops!.onPopOut({ fileName: file.name, content: original,
baselineContent: original, languageId: '', wordWrap: false, viewState: null }); });
assert.equal(detached.at(-1)?.languageId, 'nginx', 'pop-out fallback also uses the full path');
assert.equal(detached.at(-1)?.remotePath, '/etc/nginx/sites-enabled/example.com');
assert.equal(detached.at(-1)?.content, original);
} finally {
await act(async () => { renderer?.unmount(); });
for (const tab of editorTabStore.getTabs().filter(({ sessionId }) => sessionId === connection.id)) editorTabStore.close(tab.id);
activeTabStore.setActiveTabId(previousActiveTab);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
}
});
test('the SFTP overlay forwards path context to the lazy modal', () => {
const overlay = readFileSync(new URL('./SftpOverlays.tsx', import.meta.url), 'utf8');
assert.match(overlay, /<LazyTextEditorModal[\s\S]*?filePath=\{textEditorTarget\?\.fullPath\}/);
const pane = readFileSync(new URL('../editor/TextEditorPane.tsx', import.meta.url), 'utf8');
assert.match(pane, /beforeMount=\{registerNginxLanguage\}/);
});
+35
View File
@@ -5,6 +5,7 @@ import en from "../application/i18n/locales/en.ts";
import zhCN from "../application/i18n/locales/zh-CN.ts";
import zhTW from "../application/i18n/locales/zh-TW.ts";
import { filterSettingsSearchCatalog } from "./settingsSearch.ts";
import { resolveSettingsPlatform } from "./settingsSearchCatalog.ts";
import { SETTINGS_SEARCH_CATALOG } from "./settingsSearchCatalog.ts";
const EN: Record<string, string> = {
@@ -152,3 +153,37 @@ test("Sync status entries retain syncSubTab for navigation", () => {
assert.ok(backups);
assert.equal(backups?.entry.syncSubTab, "status");
});
test("macOS-only settings stay out of search on other platforms", () => {
const blurId = "appearance-window-background-blur";
// Both a targeted query and the default (empty query) list must skip it,
// otherwise selecting the hit jumps to a tab with no anchor to focus.
const macHits = filterSettingsSearchCatalog("", tEn, { platform: "mac", limit: 500 });
assert.ok(macHits.some((hit) => hit.entry.id === blurId));
for (const platform of ["win", "linux"] as const) {
const searched = filterSettingsSearchCatalog("frosted glass", tEn, { platform });
assert.equal(searched.some((hit) => hit.entry.id === blurId), false);
const defaults = filterSettingsSearchCatalog("", tEn, { platform, limit: 500 });
assert.equal(defaults.some((hit) => hit.entry.id === blurId), false);
}
});
test("platform-gated entries never hide cross-platform settings", () => {
for (const platform of ["mac", "win", "linux"] as const) {
const hits = filterSettingsSearchCatalog("opacity", tEn, { platform });
assert.ok(
hits.some((hit) => hit.entry.id === "appearance-window-opacity"),
`window opacity must stay searchable on ${platform}`,
);
}
});
test("resolveSettingsPlatform maps Electron-style platform strings", () => {
assert.equal(resolveSettingsPlatform("MacIntel"), "mac");
assert.equal(resolveSettingsPlatform("Win32"), "win");
assert.equal(resolveSettingsPlatform("Linux x86_64"), "linux");
assert.equal(resolveSettingsPlatform(""), "linux");
});
+9
View File
@@ -1,6 +1,9 @@
import { matchesSearchQuery } from "../lib/searchMatcher";
import {
SETTINGS_SEARCH_CATALOG,
isSettingsEntryAvailableOn,
resolveSettingsPlatform,
type SettingsPlatform,
type SettingsSearchEntry,
type SettingsTabId,
} from "./settingsSearchCatalog";
@@ -61,16 +64,22 @@ export function filterSettingsSearchCatalog(
catalog?: readonly SettingsSearchEntry[];
includePlugins?: boolean;
limit?: number;
/** Platform whose settings UI will receive the jump; defaults to the host. */
platform?: SettingsPlatform;
},
): SettingsSearchHit[] {
const catalog = options?.catalog ?? SETTINGS_SEARCH_CATALOG;
const includePlugins = options?.includePlugins ?? true;
const limit = options?.limit ?? 40;
const platform = options?.platform ?? resolveSettingsPlatform();
const trimmed = query.trim();
const hits: SettingsSearchHit[] = [];
for (const entry of catalog) {
if (!includePlugins && entry.tab === "plugins") continue;
// Platform-gated settings are hidden from search where their anchor is not
// rendered, so a hit always has a real control to focus.
if (!isSettingsEntryAvailableOn(entry, platform)) continue;
const hit = buildSettingsSearchHit(entry, t);
if (!trimmed) {
hits.push(hit);
+35
View File
@@ -31,6 +31,8 @@ export const SYNC_SETTINGS_SUB_TAB_IDS = ["providers", "status"] as const;
export type SyncSettingsSubTabId = (typeof SYNC_SETTINGS_SUB_TAB_IDS)[number];
export type SettingsPlatform = "mac" | "win" | "linux";
export type SettingsSearchEntry = {
/** Stable id; also used as `data-settings-anchor` on the target element. */
id: string;
@@ -47,8 +49,33 @@ export type SettingsSearchEntry = {
sectionKey?: string;
/** Extra English/Chinese aliases that are not full i18n strings. */
keywords?: readonly string[];
/**
* Platforms where the destination actually renders. Omit for cross-platform
* entries. Entries must not be offered on a platform whose settings UI hides
* the anchor, otherwise search jumps to a tab with nothing to highlight.
*/
platforms?: readonly SettingsPlatform[];
};
/** Resolve the host platform in the same terms the settings UI branches on. */
export function resolveSettingsPlatform(
platform?: string,
): SettingsPlatform {
const value = platform
?? (typeof navigator !== "undefined" ? navigator.platform : "");
if (/mac|iphone|ipad/i.test(value)) return "mac";
if (/win/i.test(value)) return "win";
return "linux";
}
export function isSettingsEntryAvailableOn(
entry: SettingsSearchEntry,
platform: SettingsPlatform,
): boolean {
if (!entry.platforms?.length) return true;
return entry.platforms.includes(platform);
}
export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
// Application
{
@@ -108,6 +135,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.appearance.windowOpacity.desc",
keywords: ["transparency", "透明"],
},
{
id: "appearance-window-background-blur",
tab: "appearance",
labelKey: "settings.appearance.windowBackgroundBlur",
descriptionKey: "settings.appearance.windowBackgroundBlur.desc",
keywords: ["blur", "frosted glass", "vibrancy", "模糊", "毛玻璃"],
platforms: ["mac"],
},
{
id: "appearance-theme",
tab: "appearance",
+19
View File
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isWindowBackgroundBlurActive,
} from './windowBackgroundBlur';
test('window background blur is visible only with a translucent surface', () => {
assert.equal(isWindowBackgroundBlurActive(true, 1), false);
assert.equal(isWindowBackgroundBlurActive(true, 0.99), true);
assert.equal(isWindowBackgroundBlurActive(true, 0.5), true);
assert.equal(isWindowBackgroundBlurActive(true, Number.NaN), false);
});
test('window background blur becomes inactive without losing the request state', () => {
assert.equal(isWindowBackgroundBlurActive(true, 0.85), true);
assert.equal(isWindowBackgroundBlurActive(true, 1), false);
assert.equal(isWindowBackgroundBlurActive(false, 0.85), false);
});
+6
View File
@@ -0,0 +1,6 @@
export function isWindowBackgroundBlurActive(
requested: boolean,
windowOpacity: number,
): boolean {
return requested && Number.isFinite(windowOpacity) && windowOpacity < 1;
}
+83 -14
View File
@@ -39,6 +39,10 @@ let settingsWindow = null;
let currentTheme = "light";
let currentLanguage = "en";
let currentWindowOpacity = 1;
let currentWindowBackgroundBlurRequested = false;
let currentWindowBackgroundColor = null;
/** Windows this module switched to a transparent backing, so restore is safe. */
const transparentChromeWindows = new WeakSet();
let cachedNativeTheme = null;
let handlersRegistered = false; // Prevent duplicate IPC handler registration
@@ -363,6 +367,7 @@ function registerAppContentWindow(win, options = {}) {
if (!win || win.isDestroyed?.()) return;
appContentWindows.add(win);
if (options.queryDirtyEditors === true) dirtyEditorWindows.add(win);
applyWindowBackgroundChromeToWindow(win, { applyBackgroundColor: true });
}
function unregisterAppContentWindow(win) {
@@ -412,21 +417,84 @@ function clampWindowOpacity(opacity) {
function applyWindowOpacityToWindow(win) {
if (!win || win.isDestroyed?.()) return;
try {
win.setOpacity?.(currentWindowOpacity);
win.setOpacity?.(isWindowBackgroundBlurActive() ? 1 : currentWindowOpacity);
} catch {
// ignore
}
applyWindowBackgroundChromeToWindow(win, { applyBackgroundColor: true });
}
function applyWindowOpacity(opacity) {
currentWindowOpacity = clampWindowOpacity(opacity);
for (const win of getAppContentWindowList()) applyWindowOpacityToWindow(win);
for (const win of getAppContentWindowList()) {
applyWindowOpacityToWindow(win);
}
if (settingsWindow && !settingsWindow.isDestroyed()) {
applyWindowOpacityToWindow(settingsWindow);
}
return currentWindowOpacity;
}
function isWindowBackgroundBlurActive() {
return process.platform === "darwin"
&& currentWindowBackgroundBlurRequested
&& currentWindowOpacity < 1;
}
function applyWindowBackgroundChromeToWindow(win, options = {}) {
if (!win || win.isDestroyed?.()) return;
try {
if (isWindowBackgroundBlurActive()) {
win.setVibrancy?.("hud");
win.setBackgroundColor?.("#00000000");
transparentChromeWindows.add(win);
return;
}
if (process.platform === "darwin") win.setVibrancy?.(null);
if (options.applyBackgroundColor !== true) return;
// An explicit theme/background-color push must always repaint, otherwise
// the native backing goes stale after theme changes. Registration-time
// chrome setup is different: it must not clobber the color the window was
// created with before any theme color has been pushed.
const wasTransparent = transparentChromeWindows.delete(win);
if (options.forceBackgroundColor === true || wasTransparent) {
// A window can be asked to drop vibrancy before any theme color has been
// pushed (cold start with blur already saved). Falling back to the active
// theme keeps the window from staying transparent.
const restoreColor = currentWindowBackgroundColor
|| THEME_COLORS[currentTheme]?.background;
if (restoreColor) win.setBackgroundColor?.(restoreColor);
}
} catch {
// ignore platform-specific chrome failures
}
}
function applyWindowBackgroundColor(color) {
const normalized = normalizeBackgroundColor(color);
if (!normalized) return false;
currentWindowBackgroundColor = normalized;
for (const win of getAppContentWindowList()) {
applyWindowBackgroundChromeToWindow(win, { applyBackgroundColor: true, forceBackgroundColor: true });
}
if (settingsWindow && !settingsWindow.isDestroyed()) {
applyWindowBackgroundChromeToWindow(settingsWindow, { applyBackgroundColor: true, forceBackgroundColor: true });
}
return true;
}
function applyWindowBackgroundBlur(enabled) {
const supported = process.platform === "darwin";
currentWindowBackgroundBlurRequested = supported && enabled === true;
for (const win of getAppContentWindowList()) {
applyWindowOpacityToWindow(win);
}
if (settingsWindow && !settingsWindow.isDestroyed()) {
applyWindowOpacityToWindow(settingsWindow);
}
return supported;
}
function getMainWindowCount() {
return getMainWindowList().length;
}
@@ -998,7 +1066,12 @@ const { createWindow } = mainWindowApi;
const { createSettingsWindowApi } = require("./windowManager/settingsWindow.cjs");
const settingsWindowApi = createSettingsWindowApi({
get settingsWindow() { return settingsWindow; },
set settingsWindow(value) { settingsWindow = value; },
set settingsWindow(value) {
settingsWindow = value;
if (value) {
applyWindowBackgroundChromeToWindow(value, { applyBackgroundColor: true });
}
},
get mainWindow() { return mainWindow; },
get currentTheme() { return currentTheme; },
get isQuitting() { return isQuitting; },
@@ -1187,21 +1260,12 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
? (nativeTheme?.shouldUseDarkColors ? "dark" : "light")
: theme;
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
forEachMainWindow((win) => win.setBackgroundColor(themeConfig.background));
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.setBackgroundColor(themeConfig.background);
}
applyWindowBackgroundColor(themeConfig.background);
return true;
});
ipcMain.handle("netcatty:setBackgroundColor", (_event, color) => {
const normalized = normalizeBackgroundColor(color);
if (!normalized) return false;
forEachMainWindow((win) => win.setBackgroundColor(normalized));
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.setBackgroundColor(normalized);
}
return true;
return applyWindowBackgroundColor(color);
});
ipcMain.handle("netcatty:setWindowOpacity", (_event, opacity) => {
@@ -1209,6 +1273,10 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
return true;
});
ipcMain.handle("netcatty:setWindowBackgroundBlur", (_event, enabled) => {
return applyWindowBackgroundBlur(enabled);
});
ipcMain.handle("netcatty:setAppIconVariant", (_event, variant) => {
const { app, BrowserWindow, nativeImage } = require("electron");
const appIconManager = require("./appIconManager.cjs");
@@ -1290,6 +1358,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
try {
const sharedWindowSetting = [
"netcatty_window_opacity_v1",
"netcatty_window_background_blur_v1",
"netcatty_hotkey_scheme_v1",
"netcatty_custom_key_bindings_v1",
"netcatty_hotkey_recording_v1",
@@ -263,11 +263,13 @@ function createMainWindowApi(ctx) {
minWidth: windowMinimumSize.width,
minHeight: windowMinimumSize.height,
backgroundColor,
transparent: isMac,
icon: appIcon,
show: false,
frame: isMac,
titleBarStyle: isMac ? "hiddenInset" : undefined,
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
...(isMac ? { visualEffectState: "active" } : {}),
...windowsChrome,
webPreferences: {
preload,
@@ -139,6 +139,7 @@ function createSettingsWindowApi(ctx) {
minWidth: 820,
minHeight: 600,
backgroundColor,
transparent: isMac,
icon: appIcon,
fullscreenable: !isMac,
// NOTE: Do NOT set parent - on macOS this causes rendering issues when dragging
@@ -150,6 +151,7 @@ function createSettingsWindowApi(ctx) {
frame: isMac,
titleBarStyle: isMac ? "hiddenInset" : undefined,
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
...(isMac ? { visualEffectState: "active" } : {}),
...windowsChrome,
webPreferences: {
preload,
@@ -0,0 +1,111 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const vm = require('node:vm');
const test = require('node:test');
function loadWindowManager(platform) {
const filename = path.resolve(__dirname, '../windowManager.cjs');
const loadedModule = { exports: {} };
const context = vm.createContext({
process: { ...process, platform },
console,
URL,
setTimeout,
clearTimeout,
});
const load = vm.runInContext(
`(function(exports, require, module, __filename, __dirname) { ${fs.readFileSync(filename, 'utf8')}\n})`,
context,
);
load(loadedModule.exports, createRequire(filename), loadedModule, filename, path.dirname(filename));
const handlers = new Map();
loadedModule.exports.registerWindowHandlers({
handle: (channel, handler) => handlers.set(channel, handler),
on: (channel, handler) => handlers.set(channel, handler),
}, {});
const state = { opacity: 1, vibrancy: null, background: '#123456' };
const win = {
isDestroyed: () => false,
setOpacity: (value) => { state.opacity = value; },
setVibrancy: (value) => { state.vibrancy = value; },
setBackgroundColor: (value) => { state.background = value; },
webContents: { id: 1, isDestroyed: () => false },
};
loadedModule.exports.registerMainWindow(win);
const invoke = (name, value) => handlers.get(`netcatty:${name}`)({}, value);
return { invoke, state };
}
test('macOS blur keeps native opacity at one and restores normal opacity on disable', () => {
const { invoke, state } = loadWindowManager('darwin');
assert.equal(state.background, '#123456');
invoke('setBackgroundColor', '#123456');
invoke('setWindowBackgroundBlur', true);
assert.equal(state.vibrancy, null);
invoke('setWindowOpacity', 0.7);
assert.equal(state.opacity, 1);
assert.equal(state.vibrancy, 'hud');
assert.equal(state.background, '#00000000');
invoke('setWindowOpacity', 0.85);
assert.equal(state.opacity, 1);
invoke('setWindowBackgroundBlur', false);
assert.equal(state.opacity, 0.85);
assert.equal(state.vibrancy, null);
assert.equal(state.background, '#123456');
});
test('restoring 100% removes native vibrancy and restores the theme background', () => {
const { invoke, state } = loadWindowManager('darwin');
invoke('setWindowOpacity', 0.7);
invoke('setWindowBackgroundBlur', true);
invoke('setBackgroundColor', '#abcdef');
assert.equal(state.background, '#00000000');
invoke('setWindowOpacity', 1);
assert.equal(state.opacity, 1);
assert.equal(state.vibrancy, null);
assert.equal(state.background, '#abcdef');
invoke('setWindowOpacity', 0.7);
assert.equal(state.opacity, 1);
assert.equal(state.vibrancy, 'hud');
});
test('unsupported platforms retain the existing opacity behavior', () => {
for (const platform of ['win32', 'linux']) {
const { invoke, state } = loadWindowManager(platform);
invoke('setWindowOpacity', 0.7);
assert.equal(invoke('setWindowBackgroundBlur', true), false);
assert.equal(state.opacity, 0.7);
assert.equal(state.vibrancy, null);
}
});
test('disabling blur before any theme color push restores the current theme background', () => {
const { invoke, state } = loadWindowManager('darwin');
// No setBackgroundColor / setTheme call yet: currentWindowBackgroundColor is
// still null, so the fallback must come from the active theme.
invoke('setWindowOpacity', 0.7);
invoke('setWindowBackgroundBlur', true);
assert.equal(state.background, '#00000000');
invoke('setWindowBackgroundBlur', false);
assert.equal(state.vibrancy, null);
assert.notEqual(state.background, '#00000000');
assert.match(String(state.background), /^#[0-9a-fA-F]{6}$/);
});
test('explicit theme and background-color pushes repaint windows that never used blur', () => {
for (const platform of ['darwin', 'win32', 'linux']) {
const { invoke, state } = loadWindowManager(platform);
assert.equal(state.background, '#123456');
// Registration-time chrome setup must leave the creation-time color alone...
invoke('setWindowBackgroundBlur', false);
assert.equal(state.background, '#123456');
// ...but an explicit theme color must always land.
invoke('setBackgroundColor', '#abcdef');
assert.equal(state.background, '#abcdef');
invoke('setTheme', 'dark');
assert.match(String(state.background), /^#[0-9a-fA-F]{6}$/);
assert.notEqual(state.background, '#abcdef');
}
});
+22 -5
View File
@@ -37,7 +37,12 @@ function startBridge({ token, authDelayMs = 0, onGetContext = null }) {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
let buffer = "";
let authenticated = false;
socket.setEncoding("utf8");
const writeLine = (payload) => {
if (socket.destroyed) return;
socket.write(`${JSON.stringify(payload)}\n`);
};
socket.on("data", (chunk) => {
buffer += chunk;
let newlineIdx;
@@ -52,17 +57,29 @@ function startBridge({ token, authDelayMs = 0, onGetContext = null }) {
token: msg.params?.token,
});
const respond = () => {
if (socket.destroyed) return;
if (msg.method === "auth/verify") {
if (msg.params?.token !== token) {
socket.write(`${JSON.stringify({
writeLine({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." },
})}\n`);
});
socket.destroy();
return;
}
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { ok: true } })}\n`);
authenticated = true;
writeLine({ jsonrpc: "2.0", id: msg.id, result: { ok: true } });
return;
}
// Match the app bridge: a call before auth/verify closes the socket.
if (!authenticated) {
writeLine({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." },
});
socket.destroy();
return;
}
if (msg.method === "netcatty/getContext" && onGetContext) {
@@ -72,11 +89,11 @@ function startBridge({ token, authDelayMs = 0, onGetContext = null }) {
return;
}
}
socket.write(`${JSON.stringify({
writeLine({
jsonrpc: "2.0",
id: msg.id,
result: { ok: true, marker: token },
})}\n`);
});
};
if (msg.method === "auth/verify" && authDelayMs > 0) {
setTimeout(respond, authDelayMs);
+33 -10
View File
@@ -122,19 +122,33 @@ function guardWriteOperation(command, { skipBlocklist = false } = {}) {
}
let tcpSocket = null;
let pendingRequests = new Map(); // id -> { resolve, reject }
let pendingRequests = new Map(); // id -> { resolve, reject, sock }
let nextRpcId = 1;
let tcpBuffer = "";
// `destroyed` stays false after the peer FIN has already moved readyState to
// "closed". Writing in that window hits writeAfterFIN and rejects the call
// with "TCP connection lost" instead of reconnecting.
function isLiveSocket(sock) {
return Boolean(
sock
&& !sock.destroyed
&& sock.readyState === "open"
&& sock.writable
&& !sock.readableEnded
&& !sock.writableEnded
);
}
function rejectPendingOnSocket(sock, externalMessage) {
if (tcpSocket && tcpSocket !== sock) return;
const managed = !externalDiscoveryPath;
if (managed && tcpSocket === sock) tcpSocket = null;
const message = managed ? "Not connected to Netcatty" : externalMessage;
for (const { reject: rej } of pendingRequests.values()) {
rej(new Error(message));
for (const [id, pending] of pendingRequests) {
if (pending.sock !== sock) continue;
pendingRequests.delete(id);
pending.reject(new Error(message));
}
pendingRequests.clear();
}
function connectTcp() {
@@ -198,15 +212,16 @@ function connectTcp() {
function sendRpc(method, params) {
return new Promise((resolve, reject) => {
if (!tcpSocket || tcpSocket.destroyed) {
if (!isLiveSocket(tcpSocket)) {
reject(new Error("Not connected to Netcatty"));
return;
}
const sock = tcpSocket;
const id = nextRpcId++;
pendingRequests.set(id, { resolve, reject });
pendingRequests.set(id, { resolve, reject, sock });
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
debugLog("rpcCall", { id, method });
tcpSocket.write(msg);
sock.write(msg);
});
}
@@ -246,7 +261,12 @@ function dropActiveSocket() {
let connecting = null;
async function connectAndAuth() {
if (tcpSocket?.destroyed) tcpSocket = null;
// Drop the reference only. In-flight requests stay bound to the old socket
// and are rejected when that socket closes; they are not replayed.
if (!isLiveSocket(tcpSocket)) {
tcpSocket = null;
tcpBuffer = "";
}
refreshExternalEndpoint();
tcpBuffer = "";
try {
@@ -263,8 +283,11 @@ async function connectAndAuth() {
}
function ensureConnected() {
if (tcpSocket && !tcpSocket.destroyed) return Promise.resolve();
// Join an in-flight reconnect before trusting tcpSocket. connectTcp assigns
// the socket before auth/verify returns; a second caller must wait for that
// re-auth instead of sending on an unauthenticated socket.
if (connecting) return connecting;
if (isLiveSocket(tcpSocket)) return Promise.resolve();
connecting = connectAndAuth().finally(() => {
connecting = null;
});
+3
View File
@@ -1149,6 +1149,9 @@ function createPreloadApi(ctx) {
setWindowOpacity: async (opacity) => {
return ipcRenderer.invoke("netcatty:setWindowOpacity", opacity);
},
setWindowBackgroundBlur: async (enabled) => {
return ipcRenderer.invoke("netcatty:setWindowBackgroundBlur", enabled);
},
setAppIconVariant: async (variant) => {
return ipcRenderer.invoke("netcatty:setAppIconVariant", variant);
},
+9
View File
@@ -2245,3 +2245,12 @@ html[data-theme-transition] *::after {
background-color: hsl(var(--background)) !important;
border-color: hsl(var(--border)) !important;
}
html.window-background-blur,
html.window-background-blur body {
background: transparent !important;
}
html.window-background-blur #root {
opacity: var(--window-content-opacity, 1);
}
+1
View File
@@ -199,6 +199,7 @@ export const STORAGE_KEY_HTTP_NETWORK_PROXY = 'netcatty_http_network_proxy_v1';
export const STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE = 'netcatty_global_default_proxy_profile_v1';
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
export const STORAGE_KEY_WINDOW_OPACITY = 'netcatty_window_opacity_v1';
export const STORAGE_KEY_WINDOW_BACKGROUND_BLUR = 'netcatty_window_background_blur_v1';
export const STORAGE_KEY_APP_ICON_VARIANT = 'netcatty_app_icon_variant_v1';
// Custom Terminal Themes
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import test from 'node:test';
import type { Monaco } from '@monaco-editor/react';
import { nginxLanguage, nginxLanguageConfiguration, registerNginxLanguage } from './nginxLanguage';
const require = createRequire(import.meta.url);
const { compile } = require('monaco-editor/esm/vs/editor/standalone/common/monarch/monarchCompile.js');
const { MonarchTokenizer } = require('monaco-editor/esm/vs/editor/standalone/common/monarch/monarchLexer.js');
// Exercise Monaco's real Monarch engine rather than approximating its regex/state rules.
function tokenize(content: string) {
const tokenizer = new MonarchTokenizer({}, {}, 'nginx', compile('nginx', nginxLanguage), {
getValue: () => 20000,
onDidChangeConfiguration: () => ({ dispose() {} }),
});
let state = tokenizer.getInitialState();
try {
return content.split('\n').map((line) => {
const result = tokenizer.tokenize(line, true, state);
state = result.endState;
return result.tokens.map((token: { offset: number; type: string }, index: number) => ({
text: line.slice(token.offset, result.tokens[index + 1]?.offset ?? line.length),
type: token.type,
}));
});
} finally {
tokenizer.dispose();
}
}
test('highlights directives, block structure, comments, strings and variables', () => {
const tokens = tokenize(`upstream backend { server 127.0.0.1:8080; }
server {
# location ignored;
location / { proxy_pass http://backend/$uri; }
set $message "hello \${host} #still string";
add_header X-Value 'literal; { # text }';
}`).flat();
for (const directive of ['upstream', 'server', 'location', 'proxy_pass', 'set', 'add_header']) {
assert.ok(tokens.some(({ text, type }) => text === directive && type === 'keyword.nginx'), directive);
}
for (const variable of ['$uri', '$message', '${host}']) {
assert.ok(tokens.some(({ text, type }) => text === variable && type === 'variable.nginx'), variable);
}
assert.ok(tokens.some(({ text, type }) => text === '# location ignored;' && type === 'comment.nginx'));
assert.ok(tokens.some(({ text, type }) => text.includes('#still string') && type === 'string.nginx'));
assert.ok(tokens.some(({ text, type }) => text.includes('literal; { # text }') && type === 'string.nginx'));
assert.ok(tokens.some(({ text, type }) => text === '{' && type === 'delimiter.curly.nginx'));
assert.ok(tokens.some(({ text, type }) => text === ';' && type === 'delimiter.nginx'));
});
test('retains argument and quote states across lines and respects escapes', () => {
const lines = tokenize(String.raw`proxy_set_header
Host $host; custom_module_directive on;
set $message "first
second \" #literal"; # comment
set $escaped value\#literal;`);
assert.ok(lines[1].some(({ text, type }) => text === 'custom_module_directive' && type === 'keyword.nginx'));
assert.ok(lines[1].every(({ text, type }) => text !== 'Host' || type !== 'keyword.nginx'));
assert.ok(lines[3].some(({ text, type }) => text.includes('#literal') && type === 'string.nginx'));
assert.ok(lines[3].some(({ text, type }) => text === '# comment' && type === 'comment.nginx'));
assert.ok(lines[4].some(({ text, type }) => text === '\\#' && type === 'string.escape.nginx'));
});
test('registers once per Monaco instance and exposes only highlighting/configuration', () => {
const calls: string[] = [];
const createMonaco = () => ({ languages: {
register: ({ id }: { id: string }) => calls.push(id),
setMonarchTokensProvider: (id: string, grammar: unknown) => {
assert.equal(grammar, nginxLanguage);
calls.push(`${id}:tokens`);
},
setLanguageConfiguration: (id: string, configuration: unknown) => {
assert.equal(configuration, nginxLanguageConfiguration);
calls.push(`${id}:configuration`);
},
} }) as unknown as Monaco;
const first = createMonaco();
registerNginxLanguage(first);
registerNginxLanguage(first);
registerNginxLanguage(createMonaco());
assert.deepEqual(calls, ['nginx', 'nginx:tokens', 'nginx:configuration', 'nginx', 'nginx:tokens', 'nginx:configuration']);
});
+66
View File
@@ -0,0 +1,66 @@
import type { Monaco } from '@monaco-editor/react';
import type { languages } from 'monaco-editor';
// Original Monarch grammar for Netcatty (same license as the project).
// Syntax reference: https://nginx.org/en/docs/beginners_guide.html#conf_structure
// Recognize directive positions rather than maintaining a list of module names.
export const nginxLanguage: languages.IMonarchLanguage = {
defaultToken: '',
tokenPostfix: '.nginx',
tokenizer: {
root: [
{ include: '@whitespace' },
[/[{}]/, 'delimiter.curly'],
[/;/, 'delimiter'],
[/[^\s{};#"'\\$]+/, 'keyword', '@arguments'],
{ include: '@values' },
],
arguments: [
{ include: '@whitespace' },
[/[{}]/, 'delimiter.curly', '@pop'],
[/;/, 'delimiter', '@pop'],
{ include: '@values' },
[/[^\s{};#"'\\$]+/, ''],
],
whitespace: [
[/\s+/, ''],
[/#.*$/, 'comment'],
],
values: [
[/\\./, 'string.escape'],
[/\$(?:\{[\w]+\}|[\w]+)/, 'variable'],
[/"/, 'string', '@doubleQuoted'],
[/'/, 'string', '@singleQuoted'],
],
doubleQuoted: [
[/\\./, 'string.escape'],
[/\$(?:\{[\w]+\}|[\w]+)/, 'variable'],
[/"/, 'string', '@pop'],
[/[^"\\$]+/, 'string'],
[/\$/, 'string'],
],
singleQuoted: [
[/\\./, 'string.escape'],
[/\$(?:\{[\w]+\}|[\w]+)/, 'variable'],
[/'/, 'string', '@pop'],
[/[^'\\$]+/, 'string'],
[/\$/, 'string'],
],
},
};
export const nginxLanguageConfiguration: languages.LanguageConfiguration = {
comments: { lineComment: '#' },
brackets: [['{', '}']],
};
const registeredInstances = new WeakSet<Monaco>();
/** Called before model creation, including when Monaco is loaded lazily. */
export function registerNginxLanguage(monaco: Monaco): void {
if (registeredInstances.has(monaco)) return;
monaco.languages.register({ id: 'nginx', aliases: ['Nginx', 'nginx'], filenames: ['nginx.conf'] });
monaco.languages.setMonarchTokensProvider('nginx', nginxLanguage);
monaco.languages.setLanguageConfiguration('nginx', nginxLanguageConfiguration);
registeredInstances.add(monaco);
}
+30
View File
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getLanguageId, getLanguageName, getSupportedLanguages } from './sftpFileUtils';
test('recognizes Nginx by filename and recognizable config directories', () => {
for (const path of [
'nginx.conf', '/etc/nginx/nginx.conf', '/usr/local/nginx/conf/nginx.conf',
'/etc/nginx/conf.d/proxy.conf', '/usr/local/etc/nginx/conf.d/site.conf',
'/usr/local/nginx/conf/conf.d/site.conf', '/etc/nginx/snippets/tls.conf',
'/etc/nginx/sites-available/default', '/etc/nginx/sites-enabled/example.com',
'C:\\nginx\\conf.d\\site.conf',
]) assert.equal(getLanguageId(path), 'nginx', path);
});
test('keeps ambiguous configs and unrelated languages unchanged', () => {
for (const path of [
'site.conf', 'other.conf', '/etc/ssh/sshd.conf', '/etc/conf.d/site.conf',
'/etc/not-nginx/conf.d/site.conf', '/etc/nginx-not/conf.d/site.conf',
'/etc/nginx/conf.d/site.conf.bak', '/etc/nginx/conf.d/settings.ini',
]) assert.equal(getLanguageId(path), path.endsWith('.bak') ? 'plaintext' : 'ini', path);
assert.equal(getLanguageId('/folder.with.dots/default'), 'plaintext');
assert.equal(getLanguageId('/etc/nginx/readme.md'), 'markdown');
assert.equal(getLanguageId('settings.ini'), 'ini');
});
test('offers Nginx for manual selection without assigning all .conf files to it', () => {
assert.equal(getLanguageName('nginx'), 'Nginx');
assert.deepEqual(getSupportedLanguages().filter(({ id }) => id === 'nginx'), [{ id: 'nginx', name: 'Nginx' }]);
assert.equal(getLanguageId('arbitrary-site.conf'), 'ini');
});
+11 -1
View File
@@ -138,7 +138,15 @@ export function isKnownBinaryFile(fileName: string): boolean {
* Get language ID for syntax highlighting
*/
export function getLanguageId(fileName: string): string {
const ext = getFileExtension(fileName);
const path = fileName.replace(/\\/g, '/');
const baseName = path.slice(path.lastIndexOf('/') + 1);
const ext = getFileExtension(baseName);
// Keep generic .conf files as INI. Only infer Nginx with filename/path context.
if (baseName.toLowerCase() === 'nginx.conf'
|| /(?:^|\/)nginx\/(?:conf\/)?(?:conf\.d|snippets)\/.+\.conf$/i.test(path)
|| /(?:^|\/)nginx\/(?:conf\/)?sites-(?:available|enabled)\/[^/]+$/i.test(path)) {
return 'nginx';
}
return EXTENSION_TO_LANGUAGE[ext] || 'plaintext';
}
@@ -181,6 +189,7 @@ export function getLanguageName(languageId: string): string {
yaml: 'YAML',
toml: 'TOML',
ini: 'INI',
nginx: 'Nginx',
sql: 'SQL',
graphql: 'GraphQL',
markdown: 'Markdown',
@@ -224,6 +233,7 @@ export interface FileAssociation {
export function getSupportedLanguages(): { id: string; name: string }[] {
const languageIds = new Set(Object.values(EXTENSION_TO_LANGUAGE));
languageIds.add('plaintext');
languageIds.add('nginx');
return Array.from(languageIds)
.map(id => ({ id, name: getLanguageName(id) }))
+1
View File
@@ -47,6 +47,7 @@ declare global {
setTheme?(theme: 'light' | 'dark' | 'system'): Promise<boolean>;
setBackgroundColor?(color: string): Promise<boolean>;
setWindowOpacity?(opacity: number): Promise<boolean>;
setWindowBackgroundBlur?(enabled: boolean): Promise<boolean>;
setAppIconVariant?(variant: import('../../domain/appIconVariant').AppIconVariant): Promise<boolean>;
setLanguage?(language: string): Promise<boolean>;
// Window controls for custom title bar (Windows/Linux)