Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a876fd67d | ||
|
|
d39cd60863 | ||
|
|
f413035295 | ||
|
|
bfd3fb4dad | ||
|
|
733e19a6f6 | ||
|
|
85b552e1a6 | ||
|
|
068730c53c |
@@ -21,6 +21,14 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
'terminal.composeBar.searchSnippets': 'Search snippets...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Pin snippets with + for quick access',
|
||||
'terminal.composeBar.noMatchingSnippets': 'No matching snippets',
|
||||
'terminal.composeBar.pinnedCount': '{count} pinned',
|
||||
'terminal.composeBar.unpinSnippet': 'Remove {label} from quick bar',
|
||||
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
|
||||
@@ -42,6 +42,14 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Закрепите сниппеты через + для быстрого доступа',
|
||||
'terminal.composeBar.noMatchingSnippets': 'Сниппеты не найдены',
|
||||
'terminal.composeBar.pinnedCount': 'Закреплено: {count}',
|
||||
'terminal.composeBar.unpinSnippet': 'Убрать {label} из панели',
|
||||
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||
|
||||
@@ -229,6 +229,14 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
'terminal.composeBar.searchSnippets': '搜索代码片段...',
|
||||
'terminal.composeBar.noPinnedSnippets': '点击 + 固定常用代码片段',
|
||||
'terminal.composeBar.noMatchingSnippets': '没有匹配的代码片段',
|
||||
'terminal.composeBar.pinnedCount': '已固定 {count} 个',
|
||||
'terminal.composeBar.unpinSnippet': '从快捷栏移除 {label}',
|
||||
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
|
||||
34
application/state/useComposeBarHeight.ts
Normal file
34
application/state/useComposeBarHeight.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from 'react';
|
||||
import { STORAGE_KEY_COMPOSE_BAR_HEIGHT } from '../../infrastructure/config/storageKeys';
|
||||
import { useStoredNumber } from './useStoredNumber';
|
||||
|
||||
export const COMPOSE_BAR_HEIGHT_DEFAULT = 120;
|
||||
export const COMPOSE_BAR_HEIGHT_MIN = 72;
|
||||
export const COMPOSE_BAR_HEIGHT_MAX = 360;
|
||||
|
||||
const HEIGHT_CLAMP = { min: COMPOSE_BAR_HEIGHT_MIN, max: COMPOSE_BAR_HEIGHT_MAX };
|
||||
|
||||
function clampHeight(height: number): number {
|
||||
return Math.max(HEIGHT_CLAMP.min, Math.min(HEIGHT_CLAMP.max, height));
|
||||
}
|
||||
|
||||
/** Persisted compose bar height; call `persist` on mouseup after a drag. */
|
||||
export function useComposeBarHeight() {
|
||||
const [height, setHeight, persist] = useStoredNumber(
|
||||
STORAGE_KEY_COMPOSE_BAR_HEIGHT,
|
||||
COMPOSE_BAR_HEIGHT_DEFAULT,
|
||||
HEIGHT_CLAMP,
|
||||
);
|
||||
|
||||
const setClampedHeight = useCallback(
|
||||
(next: number | ((prev: number) => number)) => {
|
||||
setHeight((prev) => {
|
||||
const raw = typeof next === 'function' ? next(prev) : next;
|
||||
return clampHeight(raw);
|
||||
});
|
||||
},
|
||||
[setHeight],
|
||||
);
|
||||
|
||||
return [height, setClampedHeight, persist] as const;
|
||||
}
|
||||
106
application/state/useComposeBarPinnedSnippets.ts
Normal file
106
application/state/useComposeBarPinnedSnippets.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
interface PinnedState {
|
||||
pinnedIds: string[];
|
||||
/** True when the user has never saved pins (localStorage key absent). */
|
||||
neverSaved: boolean;
|
||||
}
|
||||
|
||||
function readPinnedState(): PinnedState {
|
||||
const stored = localStorageAdapter.read<string[]>(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS);
|
||||
if (stored === null) {
|
||||
return { pinnedIds: [], neverSaved: true };
|
||||
}
|
||||
return {
|
||||
pinnedIds: Array.isArray(stored) ? stored.filter((id) => typeof id === 'string') : [],
|
||||
neverSaved: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSnippetIdKey(snippetIdKey?: string): Set<string> | null {
|
||||
if (!snippetIdKey) return null;
|
||||
const ids = snippetIdKey.split('\0').filter(Boolean);
|
||||
if (ids.length === 0) return null;
|
||||
return new Set(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted snippet IDs shown on the terminal compose bar quick strip.
|
||||
* Pass a stable `snippetIdKey` (`ids.join('\\0')`) to prune pins for deleted snippets.
|
||||
* On first run, `defaultSeedIds` are written once when pins were never saved.
|
||||
*/
|
||||
export function useComposeBarPinnedSnippets(
|
||||
snippetIdKey?: string,
|
||||
defaultSeedIds?: readonly string[],
|
||||
) {
|
||||
const [{ pinnedIds, neverSaved }, setPinnedState] = useState(readPinnedState);
|
||||
const skipNextPersistRef = useRef(true);
|
||||
const needsSeedRef = useRef(neverSaved);
|
||||
|
||||
const setPinnedIds = useCallback((updater: string[] | ((prev: string[]) => string[])) => {
|
||||
setPinnedState((prev) => {
|
||||
const nextIds = typeof updater === 'function' ? updater(prev.pinnedIds) : updater;
|
||||
return { pinnedIds: nextIds, neverSaved: false };
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextPersistRef.current) {
|
||||
skipNextPersistRef.current = false;
|
||||
return;
|
||||
}
|
||||
localStorageAdapter.write(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS, pinnedIds);
|
||||
}, [pinnedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsSeedRef.current) return;
|
||||
|
||||
const seed = defaultSeedIds?.filter(Boolean).slice(0, 4) ?? [];
|
||||
if (seed.length === 0) return;
|
||||
|
||||
const applySeed = () => {
|
||||
if (!needsSeedRef.current) return;
|
||||
needsSeedRef.current = false;
|
||||
setPinnedState({ pinnedIds: [...seed], neverSaved: false });
|
||||
};
|
||||
|
||||
const isBuiltinSeed = seed.every((id) => id.startsWith('__compose_builtin_'));
|
||||
if (!isBuiltinSeed) {
|
||||
applySeed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Brief delay so vault snippets can load before falling back to built-ins.
|
||||
const timer = setTimeout(applySeed, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [defaultSeedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const valid = parseSnippetIdKey(snippetIdKey);
|
||||
if (!valid) return;
|
||||
setPinnedIds((prev) => {
|
||||
const next = prev.filter((id) => valid.has(id) || id.startsWith('__compose_builtin_'));
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, [snippetIdKey, setPinnedIds]);
|
||||
|
||||
const pin = useCallback((id: string) => {
|
||||
setPinnedIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||
}, [setPinnedIds]);
|
||||
|
||||
const unpin = useCallback((id: string) => {
|
||||
setPinnedIds((prev) => prev.filter((x) => x !== id));
|
||||
}, [setPinnedIds]);
|
||||
|
||||
const toggle = useCallback((id: string) => {
|
||||
setPinnedIds((prev) => (
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
));
|
||||
}, [setPinnedIds]);
|
||||
|
||||
const isPinned = useCallback((id: string) => pinnedIds.includes(id), [pinnedIds]);
|
||||
|
||||
return { pinnedIds, pin, unpin, toggle, isPinned };
|
||||
}
|
||||
@@ -108,6 +108,7 @@ import { useSettingsStorageSync } from './settingsStorageSync';
|
||||
import { useSettingsIpcSync } from './settingsIpcSync';
|
||||
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
|
||||
import { useSystemSettingsEffects } from './systemSettingsEffects';
|
||||
import { applyCustomCssToDocument } from '../../lib/customCss';
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const initialCustomKeyBindingsRecord =
|
||||
@@ -777,14 +778,7 @@ export const useSettingsState = () => {
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
// Always apply CSS to document (needed on mount)
|
||||
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = 'netcatty-custom-css';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = customCSS;
|
||||
applyCustomCssToDocument(customCSS);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
hostTreeInlineGroupEditStore,
|
||||
@@ -171,7 +171,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
return (
|
||||
<div>
|
||||
{/* Group Node */}
|
||||
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => {
|
||||
if (isInlineEditing) return;
|
||||
onToggle(node.path);
|
||||
}}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
@@ -182,8 +188,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
getDropTargetClasses?.(node.path),
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
|
||||
data-section="host-tree-row"
|
||||
data-row-type="group"
|
||||
data-group-path={node.path}
|
||||
draggable={!isInlineEditing}
|
||||
onDragStart={(e) => {
|
||||
if (isInlineEditing) return;
|
||||
e.dataTransfer.setData("group-path", node.path);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -394,6 +406,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
isSelected ? "bg-primary/10" : "",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
data-section="host-tree-row"
|
||||
data-row-type="host"
|
||||
data-host-id={host.id}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => {
|
||||
@@ -496,6 +511,20 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
groupConfigs = [],
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const inlineEdit = useHostTreeInlineGroupEdit();
|
||||
const vaultTreeActions = useVaultHostTreeActions();
|
||||
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
|
||||
|
||||
const handleTreePointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!inlineEdit?.groupPath || !cancelRename) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest('[data-inline-group-edit="true"]')) return;
|
||||
const row = target.closest('[data-section="host-tree-row"]');
|
||||
if (!row) return;
|
||||
if (row.getAttribute('data-group-path') === inlineEdit.groupPath) return;
|
||||
cancelRename();
|
||||
}, [cancelRename, inlineEdit?.groupPath]);
|
||||
|
||||
// Use external state if provided, otherwise use local persistent state
|
||||
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
@@ -567,7 +596,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
}, [groupTree, sortMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1" onPointerDownCapture={handleTreePointerDownCapture}>
|
||||
{/* Expand/Collapse controls */}
|
||||
{groupTree.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
|
||||
|
||||
@@ -18,9 +18,10 @@ import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
|
||||
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
|
||||
import SettingsAITab from "./settings/tabs/SettingsAITab";
|
||||
import SettingsSyncTab from "./settings/tabs/SettingsSyncTab";
|
||||
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
@@ -50,74 +51,111 @@ class AITabErrorBoundary extends React.Component<
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const settingsTabTriggerClassName =
|
||||
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
|
||||
const settingsTabIconClassName = "shrink-0";
|
||||
const settingsTabLabelClassName = "min-w-0 truncate";
|
||||
|
||||
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
type TerminalTabSettingsProps = Pick<
|
||||
SettingsState,
|
||||
| 'terminalThemeId'
|
||||
| 'setTerminalThemeId'
|
||||
| 'followAppTerminalTheme'
|
||||
| 'setFollowAppTerminalTheme'
|
||||
| 'terminalThemeDarkId'
|
||||
| 'setTerminalThemeDarkId'
|
||||
| 'terminalThemeLightId'
|
||||
| 'setTerminalThemeLightId'
|
||||
| 'lightUiThemeId'
|
||||
| 'darkUiThemeId'
|
||||
| 'terminalFontFamilyId'
|
||||
| 'setTerminalFontFamilyId'
|
||||
| 'terminalFontSize'
|
||||
| 'setTerminalFontSize'
|
||||
| 'terminalSettings'
|
||||
| 'updateTerminalSetting'
|
||||
| 'workspaceFocusStyle'
|
||||
| 'setWorkspaceFocusStyle'
|
||||
>;
|
||||
|
||||
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme,
|
||||
terminalThemeDarkId,
|
||||
setTerminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
setTerminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
}) {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
return (
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={settings.terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
|
||||
terminalThemeLightId={settings.terminalThemeLightId}
|
||||
setTerminalThemeLightId={settings.setTerminalThemeLightId}
|
||||
lightUiThemeId={settings.lightUiThemeId}
|
||||
darkUiThemeId={settings.darkUiThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
terminalThemeId={terminalThemeId}
|
||||
setTerminalThemeId={setTerminalThemeId}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={setTerminalThemeDarkId}
|
||||
terminalThemeLightId={terminalThemeLightId}
|
||||
setTerminalThemeLightId={setTerminalThemeLightId}
|
||||
lightUiThemeId={lightUiThemeId}
|
||||
darkUiThemeId={darkUiThemeId}
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
setTerminalFontSize={setTerminalFontSize}
|
||||
terminalSettings={terminalSettings}
|
||||
updateTerminalSetting={updateTerminalSetting}
|
||||
availableFonts={availableFonts}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
workspaceFocusStyle={workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const SettingsAITabContainer: React.FC = () => {
|
||||
const aiState = useAIState();
|
||||
|
||||
return (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
setToolIntegrationMode={aiState.setToolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
setToolIntegrationMode={aiState.setToolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -333,7 +371,26 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
)}
|
||||
|
||||
{mountedTabs.has("terminal") && (
|
||||
<SettingsTerminalTabContainer settings={settings} />
|
||||
<SettingsTerminalTabContainer
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={settings.terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
|
||||
terminalThemeLightId={settings.terminalThemeLightId}
|
||||
setTerminalThemeLightId={settings.setTerminalThemeLightId}
|
||||
lightUiThemeId={settings.lightUiThemeId}
|
||||
darkUiThemeId={settings.darkUiThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("shortcuts") && (
|
||||
@@ -357,9 +414,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
||||
</React.Suspense>
|
||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("system") && (
|
||||
|
||||
@@ -50,7 +50,7 @@ import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTermi
|
||||
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
||||
import {
|
||||
@@ -1118,7 +1118,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
|
||||
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
|
||||
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isSupportedOs, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
};
|
||||
|
||||
@@ -43,11 +43,23 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-inline-group-edit="true"
|
||||
value={value}
|
||||
draggable={false}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={commit}
|
||||
onBlur={() => {
|
||||
queueMicrotask(() => {
|
||||
commit();
|
||||
});
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') {
|
||||
@@ -60,7 +72,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
|
||||
'min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
|
||||
29
components/settings/DebouncedTextarea.test.tsx
Normal file
29
components/settings/DebouncedTextarea.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = new URL('../..', import.meta.url);
|
||||
|
||||
function readProjectFile(path: string): string {
|
||||
return readFileSync(join(root.pathname, path), 'utf8');
|
||||
}
|
||||
|
||||
test('DebouncedTextarea keeps draft locally and commits on debounce/blur', () => {
|
||||
const source = readProjectFile('components/settings/DebouncedTextarea.tsx');
|
||||
|
||||
assert.match(source, /useState\(value\)/);
|
||||
assert.match(source, /onDraftChangeRef\.current\?\.\(next\)/);
|
||||
assert.match(source, /setTimeout\(\(\) =>/);
|
||||
assert.match(source, /onBlur/);
|
||||
assert.match(source, /onCommitRef\.current\(draft\)/);
|
||||
assert.match(source, /draftRef\.current !== committedRef\.current/);
|
||||
});
|
||||
|
||||
test('settings appearance uses debounced custom CSS textarea with live preview', () => {
|
||||
const source = readProjectFile('components/settings/tabs/SettingsAppearanceTab.tsx');
|
||||
|
||||
assert.match(source, /DebouncedTextarea/);
|
||||
assert.match(source, /applyCustomCssToDocument/);
|
||||
assert.match(source, /onDraftChange=\{applyCustomCssToDocument\}/);
|
||||
});
|
||||
83
components/settings/DebouncedTextarea.tsx
Normal file
83
components/settings/DebouncedTextarea.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface DebouncedTextareaProps
|
||||
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'> {
|
||||
value: string;
|
||||
onCommit: (value: string) => void;
|
||||
/** Fires on every keystroke before the debounced commit (e.g. live CSS preview). */
|
||||
onDraftChange?: (value: string) => void;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps typing responsive by holding draft text locally and committing upstream
|
||||
* after a short pause — avoids re-rendering the full settings tree per keystroke.
|
||||
*/
|
||||
export const DebouncedTextarea: React.FC<DebouncedTextareaProps> = ({
|
||||
value,
|
||||
onCommit,
|
||||
onDraftChange,
|
||||
debounceMs = 300,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const [draft, setDraft] = useState(value);
|
||||
const draftRef = useRef(value);
|
||||
const committedRef = useRef(value);
|
||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const onCommitRef = useRef(onCommit);
|
||||
const onDraftChangeRef = useRef(onDraftChange);
|
||||
|
||||
onCommitRef.current = onCommit;
|
||||
onDraftChangeRef.current = onDraftChange;
|
||||
draftRef.current = draft;
|
||||
committedRef.current = value;
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (commitTimerRef.current) {
|
||||
clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = null;
|
||||
}
|
||||
if (draftRef.current !== committedRef.current) {
|
||||
onCommitRef.current(draftRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleCommit = (next: string) => {
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = setTimeout(() => {
|
||||
commitTimerRef.current = null;
|
||||
onCommitRef.current(next);
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setDraft(next);
|
||||
onDraftChangeRef.current?.(next);
|
||||
scheduleCommit(next);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (commitTimerRef.current) {
|
||||
clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = null;
|
||||
}
|
||||
if (draft !== value) {
|
||||
onCommitRef.current(draft);
|
||||
}
|
||||
}}
|
||||
className={cn(className)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { TabsContent } from "../ui/tabs";
|
||||
|
||||
interface ToggleProps {
|
||||
@@ -195,8 +194,8 @@ export const SettingsTabContent: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ value, children }) => (
|
||||
<TabsContent value={value} className="flex-1 m-0 h-full overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="h-full overflow-y-auto overflow-x-hidden">
|
||||
<div className="p-6 space-y-6">{children}</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { memo, useCallback } from "react";
|
||||
import { applyCustomCssToDocument } from "../../../lib/customCss";
|
||||
import { DebouncedTextarea } from "../DebouncedTextarea";
|
||||
import { Check, Monitor, Moon, Palette, Sun } from "lucide-react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
|
||||
@@ -9,7 +11,7 @@ import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from ".
|
||||
import { FontSelect } from "../FontSelect";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
|
||||
export default function SettingsAppearanceTab(props: {
|
||||
function SettingsAppearanceTab(props: {
|
||||
theme: "dark" | "light" | "system";
|
||||
setTheme: (theme: "dark" | "light" | "system") => void;
|
||||
lightUiThemeId: string;
|
||||
@@ -369,9 +371,10 @@ export default function SettingsAppearanceTab(props: {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.appearance.customCss.desc")}
|
||||
</p>
|
||||
<textarea
|
||||
<DebouncedTextarea
|
||||
value={customCSS}
|
||||
onChange={(e) => setCustomCSS(e.target.value)}
|
||||
onCommit={setCustomCSS}
|
||||
onDraftChange={applyCustomCssToDocument}
|
||||
placeholder={t("settings.appearance.customCss.placeholder")}
|
||||
className="w-full h-32 px-3 py-2 text-xs font-mono bg-muted/50 border border-border rounded-lg resize-y focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
spellCheck={false}
|
||||
@@ -380,3 +383,5 @@ export default function SettingsAppearanceTab(props: {
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SettingsAppearanceTab);
|
||||
|
||||
@@ -1019,4 +1019,4 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsSystemTab;
|
||||
export default React.memo(SettingsSystemTab);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from "../../../do
|
||||
|
||||
import { KeywordHighlightRulesEditor, ThemePreviewButton } from "./SettingsTerminalTabControls";
|
||||
import { TerminalBehaviorSettings } from "./TerminalBehaviorSettings";
|
||||
export default function SettingsTerminalTab(props: {
|
||||
function SettingsTerminalTab(props: {
|
||||
terminalThemeId: string;
|
||||
setTerminalThemeId: (id: string) => void;
|
||||
followAppTerminalTheme: boolean;
|
||||
@@ -1015,3 +1015,5 @@ export default function SettingsTerminalTab(props: {
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(SettingsTerminalTab);
|
||||
|
||||
@@ -1,148 +1,526 @@
|
||||
/**
|
||||
* Terminal Compose Bar
|
||||
* An immersive, borderless prompt bar that blends into the terminal's
|
||||
* background — like the Claude Code compose area. Enter sends, Escape
|
||||
* closes, Shift+Enter inserts a newline. The only visible chrome is a
|
||||
* hair-line top border separating it from the terminal output.
|
||||
* An immersive prompt bar below the terminal with a quick-snippet strip,
|
||||
* user-resizable height, and terminal-matched chrome.
|
||||
*/
|
||||
import { Radio, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useComposeBarHeight } from '../../application/state/useComposeBarHeight';
|
||||
import { useComposeBarPinnedSnippets } from '../../application/state/useComposeBarPinnedSnippets';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { resolveSnippetCommand } from '../SnippetExecutionProvider';
|
||||
import { Snippet } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import {
|
||||
buildSnippetIdKey,
|
||||
filterComposeBarSnippets,
|
||||
mergeComposeBarSnippetMap,
|
||||
resolveComposeBarDefaultSeedIds,
|
||||
} from './composeBarHelpers';
|
||||
|
||||
const SNIPPET_STRIP_HEIGHT = 30;
|
||||
const RESIZE_HANDLE_HEIGHT = 6;
|
||||
|
||||
type ComposeBarTheme = {
|
||||
resolvedBg: string;
|
||||
resolvedFg: string;
|
||||
borderColor: string;
|
||||
mutedFg: string;
|
||||
hoverBg: string;
|
||||
chipBg: string;
|
||||
chipHoverBg: string;
|
||||
};
|
||||
|
||||
function buildTheme(themeColors?: { background: string; foreground: string }): ComposeBarTheme {
|
||||
const bg = themeColors?.background ?? '#0a0a0a';
|
||||
const fg = themeColors?.foreground ?? '#d4d4d4';
|
||||
const resolvedBg = 'var(--terminal-ui-bg, ' + bg + ')';
|
||||
const resolvedFg = 'var(--terminal-ui-fg, ' + fg + ')';
|
||||
return {
|
||||
resolvedBg,
|
||||
resolvedFg,
|
||||
borderColor: `color-mix(in srgb, ${resolvedFg} 8%, ${resolvedBg} 92%)`,
|
||||
mutedFg: `color-mix(in srgb, ${resolvedFg} 55%, ${resolvedBg} 45%)`,
|
||||
hoverBg: `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`,
|
||||
chipBg: `color-mix(in srgb, ${resolvedFg} 6%, ${resolvedBg} 94%)`,
|
||||
chipHoverBg: `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`,
|
||||
};
|
||||
}
|
||||
|
||||
interface ComposeBarSnippetChipProps {
|
||||
snippet: Snippet;
|
||||
theme: ComposeBarTheme;
|
||||
onActivate: (snippet: Snippet, sendImmediately: boolean) => void;
|
||||
onUnpin: (id: string) => void;
|
||||
unpinLabel: string;
|
||||
clickHint: string;
|
||||
}
|
||||
|
||||
const ComposeBarSnippetChip = memo(function ComposeBarSnippetChip({
|
||||
snippet,
|
||||
theme,
|
||||
onActivate,
|
||||
onUnpin,
|
||||
unpinLabel,
|
||||
clickHint,
|
||||
}: ComposeBarSnippetChipProps) {
|
||||
const commandPreview = snippet.command.split('\n')[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/chip flex items-stretch h-6 max-w-[168px] rounded overflow-hidden flex-shrink-0 transition-colors duration-150"
|
||||
style={{ backgroundColor: theme.chipBg, color: theme.resolvedFg }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = theme.chipHoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = theme.chipBg;
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 px-2 text-[10px] font-mono truncate text-left"
|
||||
onClick={(e) => { void onActivate(snippet, e.shiftKey); }}
|
||||
>
|
||||
{snippet.label}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="font-medium">{snippet.label}</p>
|
||||
<p className="text-[10px] opacity-80 mt-0.5 font-mono line-clamp-2">
|
||||
{commandPreview}
|
||||
</p>
|
||||
<p className="text-[10px] opacity-60 mt-1">{clickHint}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center justify-center w-5 shrink-0',
|
||||
'opacity-40 hover:opacity-100 group-hover/chip:opacity-70',
|
||||
'transition-opacity duration-150',
|
||||
)}
|
||||
style={{ color: theme.mutedFg }}
|
||||
aria-label={unpinLabel}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnpin(snippet.id);
|
||||
}}
|
||||
>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ComposeBarSnippetManagePopoverProps {
|
||||
snippets: Snippet[];
|
||||
pinnedCount: number;
|
||||
theme: ComposeBarTheme;
|
||||
isPinned: (id: string) => boolean;
|
||||
onTogglePin: (id: string) => void;
|
||||
manageLabel: string;
|
||||
searchPlaceholder: string;
|
||||
noSnippetsLabel: string;
|
||||
noMatchingLabel: string;
|
||||
pinnedCountLabel: string;
|
||||
}
|
||||
|
||||
const ComposeBarSnippetManagePopover = memo(function ComposeBarSnippetManagePopover({
|
||||
snippets,
|
||||
pinnedCount,
|
||||
theme,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
manageLabel,
|
||||
searchPlaceholder,
|
||||
noSnippetsLabel,
|
||||
noMatchingLabel,
|
||||
pinnedCountLabel,
|
||||
}: ComposeBarSnippetManagePopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredSnippets = useMemo(
|
||||
() => filterComposeBarSnippets(snippets, search),
|
||||
[snippets, search],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setSearch('');
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 flex-shrink-0 flex items-center justify-center rounded transition-colors duration-150"
|
||||
style={{ color: theme.mutedFg }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = theme.hoverBg;
|
||||
e.currentTarget.style.color = theme.resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = theme.mutedFg;
|
||||
}}
|
||||
aria-label={manageLabel}
|
||||
title={manageLabel}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-72 p-0"
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
style={{
|
||||
backgroundColor: theme.resolvedBg,
|
||||
borderColor: theme.borderColor,
|
||||
color: theme.resolvedFg,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-2.5 py-2 border-b"
|
||||
style={{ borderColor: theme.borderColor }}
|
||||
>
|
||||
<p className="text-[11px] font-semibold mb-1.5">{manageLabel}</p>
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded px-2 h-7"
|
||||
style={{ backgroundColor: theme.chipBg }}
|
||||
>
|
||||
<Search size={11} style={{ color: theme.mutedFg }} className="shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="flex-1 min-w-0 bg-transparent text-[11px] font-mono outline-none placeholder:opacity-60"
|
||||
style={{ color: theme.resolvedFg }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto p-1">
|
||||
{snippets.length === 0 ? (
|
||||
<p className="text-[11px] px-2 py-3 text-center" style={{ color: theme.mutedFg }}>
|
||||
{noSnippetsLabel}
|
||||
</p>
|
||||
) : filteredSnippets.length === 0 ? (
|
||||
<p className="text-[11px] px-2 py-3 text-center" style={{ color: theme.mutedFg }}>
|
||||
{noMatchingLabel}
|
||||
</p>
|
||||
) : (
|
||||
filteredSnippets.map((snippet) => {
|
||||
const pinned = isPinned(snippet.id);
|
||||
return (
|
||||
<button
|
||||
key={snippet.id}
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-left transition-colors duration-150"
|
||||
style={{
|
||||
color: theme.resolvedFg,
|
||||
backgroundColor: pinned ? theme.chipHoverBg : 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!pinned) e.currentTarget.style.backgroundColor = theme.hoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = pinned ? theme.chipHoverBg : 'transparent';
|
||||
}}
|
||||
onClick={() => onTogglePin(snippet.id)}
|
||||
>
|
||||
<Pin
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
color: pinned ? theme.resolvedFg : theme.mutedFg,
|
||||
fill: pinned ? 'currentColor' : 'none',
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 min-w-0 truncate text-[11px] font-mono">
|
||||
{snippet.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{pinnedCount > 0 && (
|
||||
<div
|
||||
className="px-2.5 py-1.5 border-t text-[10px]"
|
||||
style={{ borderColor: theme.borderColor, color: theme.mutedFg }}
|
||||
>
|
||||
{pinnedCountLabel}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
export interface TerminalComposeBarProps {
|
||||
onSend: (text: string) => void;
|
||||
onClose: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
themeColors?: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
};
|
||||
onSend: (text: string) => void;
|
||||
onClose: () => void;
|
||||
onSnippetClick?: (snippet: Snippet) => void;
|
||||
snippets?: Snippet[];
|
||||
isBroadcastEnabled?: boolean;
|
||||
themeColors?: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
onSend,
|
||||
onClose,
|
||||
isBroadcastEnabled,
|
||||
themeColors,
|
||||
onSend,
|
||||
onClose,
|
||||
onSnippetClick,
|
||||
snippets = [],
|
||||
isBroadcastEnabled,
|
||||
themeColors,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
const { t } = useI18n();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
const resizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const [barHeight, setBarHeight, persistBarHeight] = useComposeBarHeight();
|
||||
const heightRef = useRef(barHeight);
|
||||
|
||||
// Auto-focus on mount
|
||||
useEffect(() => {
|
||||
// Small delay to ensure the element is rendered
|
||||
const timer = setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
const snippetIdKey = useMemo(
|
||||
() => buildSnippetIdKey(snippets.map((snippet) => snippet.id)),
|
||||
[snippets],
|
||||
);
|
||||
const defaultSeedIds = useMemo(
|
||||
() => resolveComposeBarDefaultSeedIds(snippets),
|
||||
[snippets],
|
||||
);
|
||||
const { pinnedIds, unpin, toggle, isPinned } = useComposeBarPinnedSnippets(
|
||||
snippetIdKey,
|
||||
defaultSeedIds,
|
||||
);
|
||||
|
||||
// Auto-resize textarea
|
||||
const handleInput = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
|
||||
}, []);
|
||||
heightRef.current = barHeight;
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
const text = el.value;
|
||||
if (!text) return;
|
||||
onSend(text);
|
||||
el.value = '';
|
||||
el.style.height = 'auto';
|
||||
el.focus();
|
||||
}, [onSend]);
|
||||
const theme = useMemo(() => buildTheme(themeColors), [themeColors]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}, [handleSend, onClose]);
|
||||
const snippetsById = useMemo(
|
||||
() => mergeComposeBarSnippetMap(snippets),
|
||||
[snippets],
|
||||
);
|
||||
|
||||
const bg = themeColors?.background ?? '#0a0a0a';
|
||||
const fg = themeColors?.foreground ?? '#d4d4d4';
|
||||
const resolvedBg = 'var(--terminal-ui-bg, ' + bg + ')';
|
||||
const resolvedFg = 'var(--terminal-ui-fg, ' + fg + ')';
|
||||
const pinnedSnippets = useMemo(
|
||||
() => pinnedIds
|
||||
.map((id) => snippetsById.get(id))
|
||||
.filter((snippet): snippet is Snippet => Boolean(snippet)),
|
||||
[pinnedIds, snippetsById],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: resolvedBg,
|
||||
borderTop: `1px solid color-mix(in srgb, ${resolvedFg} 8%, ${resolvedBg} 92%)`,
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Broadcast indicator */}
|
||||
{isBroadcastEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center cursor-default">
|
||||
<Radio size={14} className="text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.composeBar.broadcasting")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
const clickHint = t('terminal.composeBar.snippetClickHint');
|
||||
|
||||
{/* Borderless input — lives flush on the terminal bg so the
|
||||
bar feels like part of the terminal rather than a panel. */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 resize-none bg-transparent border-none px-0 py-0",
|
||||
"text-xs font-mono leading-relaxed outline-none",
|
||||
"placeholder:opacity-70",
|
||||
)}
|
||||
style={{
|
||||
color: resolvedFg,
|
||||
minHeight: '20px',
|
||||
maxHeight: '120px',
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={t("terminal.composeBar.placeholder")}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => { isComposingRef.current = true; }}
|
||||
onCompositionEnd={() => { isComposingRef.current = false; }}
|
||||
/>
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
{/* Minimal close button — no filled bg, hover only. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
|
||||
style={{
|
||||
color: `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`,
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`;
|
||||
e.currentTarget.style.color = resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`;
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.composeBar.close")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
useEffect(() => () => {
|
||||
resizeCleanupRef.current?.();
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
const text = el.value;
|
||||
if (!text) return;
|
||||
onSend(text);
|
||||
el.value = '';
|
||||
el.focus();
|
||||
}, [onSend]);
|
||||
|
||||
const insertCommand = useCallback((command: string) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
const prefix = el.value && !el.value.endsWith('\n') ? '\n' : '';
|
||||
el.value = el.value ? `${el.value}${prefix}${command}` : command;
|
||||
el.focus();
|
||||
}, []);
|
||||
|
||||
const handleSnippetActivate = useCallback(async (snippet: Snippet, sendImmediately: boolean) => {
|
||||
if (sendImmediately) {
|
||||
if (onSnippetClick) {
|
||||
onSnippetClick(snippet);
|
||||
} else {
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command !== null) onSend(command);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
insertCommand(command);
|
||||
}, [insertCommand, onSend, onSnippetClick]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}, [handleSend, onClose]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
resizeCleanupRef.current?.();
|
||||
|
||||
const startY = e.clientY;
|
||||
const startHeight = heightRef.current;
|
||||
|
||||
document.body.style.cursor = 'ns-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientY - startY;
|
||||
setBarHeight(startHeight - delta);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
resizeCleanupRef.current = null;
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
persistBarHeight(heightRef.current);
|
||||
cleanup();
|
||||
};
|
||||
|
||||
resizeCleanupRef.current = cleanup;
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, [persistBarHeight, setBarHeight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col"
|
||||
style={{
|
||||
height: barHeight,
|
||||
backgroundColor: theme.resolvedBg,
|
||||
borderTop: `1px solid ${theme.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label={t('terminal.composeBar.resize')}
|
||||
className="flex-shrink-0 flex items-center justify-center cursor-ns-resize group"
|
||||
style={{ height: RESIZE_HANDLE_HEIGHT }}
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<GripHorizontal
|
||||
size={12}
|
||||
className="opacity-0 group-hover:opacity-60 transition-opacity"
|
||||
style={{ color: theme.mutedFg }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center gap-1 px-2 min-w-0"
|
||||
style={{ height: SNIPPET_STRIP_HEIGHT }}
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-x-auto scrollbar-thin">
|
||||
{pinnedSnippets.map((snippet) => (
|
||||
<ComposeBarSnippetChip
|
||||
key={snippet.id}
|
||||
snippet={snippet}
|
||||
theme={theme}
|
||||
clickHint={clickHint}
|
||||
unpinLabel={t('terminal.composeBar.unpinSnippet', { label: snippet.label })}
|
||||
onUnpin={unpin}
|
||||
onActivate={handleSnippetActivate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
<ComposeBarSnippetManagePopover
|
||||
snippets={snippets}
|
||||
pinnedCount={pinnedSnippets.length}
|
||||
theme={theme}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={toggle}
|
||||
manageLabel={t('terminal.composeBar.manageSnippets')}
|
||||
searchPlaceholder={t('terminal.composeBar.searchSnippets')}
|
||||
noSnippetsLabel={t('terminal.toolbar.noSnippets')}
|
||||
noMatchingLabel={t('terminal.composeBar.noMatchingSnippets')}
|
||||
pinnedCountLabel={t('terminal.composeBar.pinnedCount', { count: pinnedSnippets.length })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 px-3 pt-1.5 pb-2 flex flex-col">
|
||||
<div className="flex flex-1 min-h-0 items-start gap-1.5">
|
||||
{isBroadcastEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center cursor-default pt-0.5 flex-shrink-0">
|
||||
<Radio size={14} className="text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.composeBar.broadcasting')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
'flex-1 min-w-0 min-h-0 h-full resize-none bg-transparent border-none px-0 py-0',
|
||||
'text-xs font-mono leading-relaxed outline-none',
|
||||
'placeholder:opacity-70 overflow-y-auto',
|
||||
)}
|
||||
style={{ color: theme.resolvedFg }}
|
||||
placeholder={t('terminal.composeBar.placeholder')}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => { isComposingRef.current = true; }}
|
||||
onCompositionEnd={() => { isComposingRef.current = false; }}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
|
||||
style={{
|
||||
color: theme.mutedFg,
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = theme.hoverBg;
|
||||
e.currentTarget.style.color = theme.resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = theme.mutedFg;
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.composeBar.close')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalComposeBar;
|
||||
|
||||
@@ -378,6 +378,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
executeSnippetCommand(text, false);
|
||||
}
|
||||
}}
|
||||
onSnippetClick={(snippet) => void executeSnippet(snippet)}
|
||||
snippets={snippets}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
termRef.current?.focus();
|
||||
|
||||
66
components/terminal/composeBarHelpers.test.ts
Normal file
66
components/terminal/composeBarHelpers.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { Snippet } from '../../types';
|
||||
import {
|
||||
buildSnippetIdKey,
|
||||
clampComposeBarHeight,
|
||||
COMPOSE_BAR_BUILTIN_SNIPPET_IDS,
|
||||
COMPOSE_BAR_MAX_HEIGHT,
|
||||
COMPOSE_BAR_MIN_HEIGHT,
|
||||
filterComposeBarSnippets,
|
||||
resolveComposeBarDefaultSeedIds,
|
||||
} from './composeBarHelpers';
|
||||
|
||||
const sampleSnippets: Snippet[] = [
|
||||
{ id: 'a', label: 'List files', command: 'ls -la', package: 'utils' },
|
||||
{ id: 'b', label: 'Disk usage', command: 'df -h', package: 'monitor' },
|
||||
{ id: 'c', label: 'Restart nginx', command: 'systemctl restart nginx' },
|
||||
];
|
||||
|
||||
test('clampComposeBarHeight clamps below minimum', () => {
|
||||
assert.equal(clampComposeBarHeight(10), COMPOSE_BAR_MIN_HEIGHT);
|
||||
});
|
||||
|
||||
test('clampComposeBarHeight clamps above maximum', () => {
|
||||
assert.equal(clampComposeBarHeight(999), COMPOSE_BAR_MAX_HEIGHT);
|
||||
});
|
||||
|
||||
test('clampComposeBarHeight passes through valid values', () => {
|
||||
assert.equal(clampComposeBarHeight(150), 150);
|
||||
});
|
||||
|
||||
test('filterComposeBarSnippets returns all snippets sorted when query is empty', () => {
|
||||
assert.deepEqual(
|
||||
filterComposeBarSnippets(sampleSnippets, '').map((s) => s.id),
|
||||
['b', 'a', 'c'],
|
||||
);
|
||||
});
|
||||
|
||||
test('filterComposeBarSnippets filters by label, command, and package', () => {
|
||||
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'nginx').map((s) => s.id), ['c']);
|
||||
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'df').map((s) => s.id), ['b']);
|
||||
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'utils').map((s) => s.id), ['a']);
|
||||
});
|
||||
|
||||
test('filterComposeBarSnippets returns empty list when nothing matches', () => {
|
||||
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'missing'), []);
|
||||
});
|
||||
|
||||
test('buildSnippetIdKey joins ids with a null delimiter', () => {
|
||||
assert.equal(buildSnippetIdKey(['a', 'b']), 'a\0b');
|
||||
});
|
||||
|
||||
test('resolveComposeBarDefaultSeedIds uses vault snippets when available', () => {
|
||||
assert.deepEqual(
|
||||
resolveComposeBarDefaultSeedIds(sampleSnippets).map((id) => id),
|
||||
['b', 'a', 'c'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveComposeBarDefaultSeedIds falls back to built-ins when vault is empty', () => {
|
||||
assert.deepEqual(
|
||||
resolveComposeBarDefaultSeedIds([]),
|
||||
COMPOSE_BAR_BUILTIN_SNIPPET_IDS.slice(0, 4),
|
||||
);
|
||||
});
|
||||
57
components/terminal/composeBarHelpers.ts
Normal file
57
components/terminal/composeBarHelpers.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Snippet } from '../../types';
|
||||
|
||||
export const COMPOSE_BAR_DEFAULT_HEIGHT = 120;
|
||||
export const COMPOSE_BAR_MIN_HEIGHT = 72;
|
||||
export const COMPOSE_BAR_MAX_HEIGHT = 360;
|
||||
|
||||
export function clampComposeBarHeight(height: number): number {
|
||||
return Math.max(COMPOSE_BAR_MIN_HEIGHT, Math.min(COMPOSE_BAR_MAX_HEIGHT, height));
|
||||
}
|
||||
|
||||
export function filterComposeBarSnippets(snippets: Snippet[], query: string): Snippet[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const filtered = normalized
|
||||
? snippets.filter((snippet) => (
|
||||
snippet.label.toLowerCase().includes(normalized)
|
||||
|| snippet.command.toLowerCase().includes(normalized)
|
||||
|| (snippet.package?.toLowerCase().includes(normalized) ?? false)
|
||||
))
|
||||
: snippets;
|
||||
return filtered.slice().sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
export function buildSnippetIdKey(snippetIds: readonly string[]): string {
|
||||
return snippetIds.join('\0');
|
||||
}
|
||||
|
||||
/** Built-in quick commands shown until the user customizes the strip. */
|
||||
export const COMPOSE_BAR_BUILTIN_SNIPPETS: Snippet[] = [
|
||||
{ id: '__compose_builtin_ls', label: 'ls -la', command: 'ls -la' },
|
||||
{ id: '__compose_builtin_df', label: 'df -h', command: 'df -h' },
|
||||
{ id: '__compose_builtin_free', label: 'free -h', command: 'free -h' },
|
||||
{ id: '__compose_builtin_pwd', label: 'pwd', command: 'pwd' },
|
||||
];
|
||||
|
||||
export const COMPOSE_BAR_BUILTIN_SNIPPET_IDS = COMPOSE_BAR_BUILTIN_SNIPPETS.map((s) => s.id);
|
||||
|
||||
export const COMPOSE_BAR_DEFAULT_SEED_COUNT = 4;
|
||||
|
||||
export function resolveComposeBarDefaultSeedIds(snippets: Snippet[]): string[] {
|
||||
if (snippets.length > 0) {
|
||||
return filterComposeBarSnippets(snippets, '')
|
||||
.slice(0, COMPOSE_BAR_DEFAULT_SEED_COUNT)
|
||||
.map((snippet) => snippet.id);
|
||||
}
|
||||
return COMPOSE_BAR_BUILTIN_SNIPPET_IDS.slice(0, COMPOSE_BAR_DEFAULT_SEED_COUNT);
|
||||
}
|
||||
|
||||
export function mergeComposeBarSnippetMap(snippets: Snippet[]): Map<string, Snippet> {
|
||||
const map = new Map<string, Snippet>();
|
||||
for (const builtin of COMPOSE_BAR_BUILTIN_SNIPPETS) {
|
||||
map.set(builtin.id, builtin);
|
||||
}
|
||||
for (const snippet of snippets) {
|
||||
map.set(snippet.id, snippet);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalFontWeight,
|
||||
} from "../../../domain/terminalAppearance";
|
||||
import { resolveFontWeightBold } from "../../../lib/fontWeightAvailability";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isMacPlatform } from "../../../lib/utils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
@@ -213,11 +214,8 @@ const csiParamsInclude = (
|
||||
|
||||
/**
|
||||
* Extract the primary font family from a CSS font-family string that may
|
||||
* include fallback fonts. `document.fonts.check` returns `false` when *any*
|
||||
* listed font is still loading, so passing the entire CJK fallback stack
|
||||
* causes false negatives during early terminal creation – which in turn makes
|
||||
* `fontWeightBold` fall back to the normal weight and renders bold text too
|
||||
* thin.
|
||||
* include fallback fonts. Used by autocomplete and other helpers that need
|
||||
* the first face without the CJK / icon fallback stack.
|
||||
*/
|
||||
export const primaryFontFamily = (fontFamily: string): string => {
|
||||
// Split on commas that are NOT inside quotes to handle font names like "Foo, Bar"
|
||||
@@ -278,13 +276,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const keywordHighlightEnabled = settings?.keywordHighlightEnabled ?? false;
|
||||
const kittyKeyboardMode = createKittyKeyboardModeState();
|
||||
|
||||
const resolvedFontWeightBold = (() => {
|
||||
if (typeof document === "undefined" || !document.fonts?.check) {
|
||||
return fontWeightBold;
|
||||
}
|
||||
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||||
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
|
||||
})();
|
||||
const resolvedFontWeightBold = resolveFontWeightBold({
|
||||
fontFamilyCss: fontFamily,
|
||||
normalWeight: fontWeight,
|
||||
desiredBoldWeight: fontWeightBold,
|
||||
fontSize: effectiveFontSize,
|
||||
});
|
||||
|
||||
const term = new XTerm({
|
||||
...performanceConfig.options,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
import { useRef } from 'react';
|
||||
import { resolveFontWeightBold } from '../../lib/fontWeightAvailability';
|
||||
|
||||
type TerminalEffectsContext = Record<string, any>;
|
||||
|
||||
@@ -47,7 +48,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
|
||||
}
|
||||
|
||||
export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
|
||||
|
||||
// Remember the last layout we successfully refit while visible so revisiting
|
||||
// the same workspace tab does not replay expensive force-fit/WebGL recovery.
|
||||
@@ -453,16 +454,12 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
| 700
|
||||
| 800
|
||||
| 900;
|
||||
const resolvedFontWeightBold = (() => {
|
||||
const fontFamily = termRef.current?.options.fontFamily || "";
|
||||
if (typeof document === "undefined" || !document.fonts?.check) {
|
||||
return terminalSettings.fontWeightBold;
|
||||
}
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||||
return document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: effectiveFontWeight;
|
||||
})();
|
||||
const resolvedFontWeightBold = resolveFontWeightBold({
|
||||
fontFamilyCss: termRef.current?.options.fontFamily || "",
|
||||
normalWeight: effectiveFontWeight,
|
||||
desiredBoldWeight: terminalSettings.fontWeightBold,
|
||||
fontSize: effectiveFontSize,
|
||||
});
|
||||
|
||||
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
|
||||
| 100
|
||||
@@ -662,23 +659,22 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
}
|
||||
|
||||
if (terminalSettings && termRef.current) {
|
||||
const fontFamily = termRef.current.options?.fontFamily || "";
|
||||
if (typeof document !== "undefined" && document.fonts?.check) {
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||||
const resolvedBold = document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: effectiveFontWeight;
|
||||
termRef.current.options.fontWeightBold = resolvedBold as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
| 400
|
||||
| 500
|
||||
| 600
|
||||
| 700
|
||||
| 800
|
||||
| 900;
|
||||
}
|
||||
const resolvedBold = resolveFontWeightBold({
|
||||
fontFamilyCss: termRef.current.options?.fontFamily || "",
|
||||
normalWeight: effectiveFontWeight,
|
||||
desiredBoldWeight: terminalSettings.fontWeightBold,
|
||||
fontSize: effectiveFontSize,
|
||||
});
|
||||
termRef.current.options.fontWeightBold = resolvedBold as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
| 400
|
||||
| 500
|
||||
| 600
|
||||
| 700
|
||||
| 800
|
||||
| 900;
|
||||
}
|
||||
|
||||
const id = sessionRef.current;
|
||||
|
||||
@@ -420,9 +420,9 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
|
||||
color: theme.termFg,
|
||||
backgroundColor: isDragOver ? theme.rowDropBg : undefined,
|
||||
}}
|
||||
draggable={canDrag}
|
||||
draggable={canDrag && !isInlineEditing}
|
||||
onDragStart={(event) => {
|
||||
if (!canDrag) return;
|
||||
if (!canDrag || isInlineEditing) return;
|
||||
event.dataTransfer.setData(HOST_TREE_DRAG_GROUP_PATH, node.path);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
@@ -445,8 +445,12 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
|
||||
onMouseLeave={(event) => {
|
||||
if (!isDragOver) event.currentTarget.style.backgroundColor = '';
|
||||
}}
|
||||
onClick={() => onTogglePath(node.path)}
|
||||
onClick={() => {
|
||||
if (isInlineEditing) return;
|
||||
onTogglePath(node.path);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (isInlineEditing) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onTogglePath(node.path);
|
||||
@@ -711,6 +715,24 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
|
||||
handleDropToParent(null, event.dataTransfer);
|
||||
}, [canDrag, handleDropToParent]);
|
||||
|
||||
const handleListPointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!menuActions) return;
|
||||
const editingGroupPath = inlineEdit?.groupPath;
|
||||
const editingHostId = inlineHostEdit?.hostId;
|
||||
if (!editingGroupPath && !editingHostId) return;
|
||||
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest('[data-inline-group-edit="true"]')) return;
|
||||
const row = target.closest('[data-section="terminal-host-tree-sidebar-row"]');
|
||||
if (!row) return;
|
||||
if (editingGroupPath && row.getAttribute('data-group-path') === editingGroupPath) return;
|
||||
if (editingHostId && row.getAttribute('data-host-id') === editingHostId) return;
|
||||
|
||||
if (editingGroupPath) menuActions.cancelInlineGroupEdit();
|
||||
if (editingHostId) menuActions.cancelInlineHostEdit();
|
||||
}, [inlineEdit?.groupPath, inlineHostEdit?.hostId, menuActions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inlineEdit?.shouldScrollIntoView || !inlineEdit.isNew) return;
|
||||
const index = flatRows.findIndex(
|
||||
@@ -971,6 +993,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
|
||||
className="flex-1 min-h-0 py-1"
|
||||
data-section="terminal-host-tree-sidebar-content"
|
||||
style={dragOverTarget?.kind === 'root' ? { backgroundColor: theme.rowDropBg } : undefined}
|
||||
onPointerDownCapture={handleListPointerDownCapture}
|
||||
onDragOver={handleRootDragOver}
|
||||
onDragLeave={handleRootDragLeave}
|
||||
onDrop={handleRootDrop}
|
||||
|
||||
@@ -198,6 +198,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
onToggleWorkspaceViewModeRef: s.onToggleWorkspaceViewModeRef,
|
||||
prevFocusedSessionIdRef,
|
||||
previewTargetSessionId: themeState.previewTargetSessionId,
|
||||
refocusActiveTerminalSession: s.refocusActiveTerminalSession,
|
||||
requestAnimationFrame,
|
||||
ResizeObserver,
|
||||
sessionActivityStore: s.sessionActivityStore,
|
||||
|
||||
@@ -16,11 +16,13 @@ function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
composeBarThemeColors,
|
||||
focusedSessionId,
|
||||
handleComposeSend,
|
||||
handleSnippetFromPanel,
|
||||
isBroadcastEnabled,
|
||||
isComposeBarOpen,
|
||||
isTerminalLayerVisible,
|
||||
refocusTerminalSession,
|
||||
setIsComposeBarOpen,
|
||||
snippets,
|
||||
TerminalComposeBar,
|
||||
workspaceOuterRef,
|
||||
} = ctx;
|
||||
@@ -46,6 +48,8 @@ function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
{activeWorkspace && isComposeBarOpen && (
|
||||
<TerminalComposeBar
|
||||
onSend={handleComposeSend}
|
||||
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
|
||||
snippets={snippets}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
refocusTerminalSession(focusedSessionId);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { terminalLayoutSuppressStore } from '../../application/state/terminalLay
|
||||
type TerminalLayerEffectsContext = Record<string, any>;
|
||||
|
||||
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
const { activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, previewTargetSessionId, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setScriptsMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemeMountedTabIds, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
const { activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, previewTargetSessionId, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setScriptsMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemeMountedTabIds, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
|
||||
useEffect(() => {
|
||||
const liveSessionIds = new Set(sessions.map((session) => session.id));
|
||||
@@ -310,6 +310,23 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
}, [isFocusMode, dropHint]);
|
||||
|
||||
const wasTerminalLayerVisibleRef = useRef(false);
|
||||
const prevActiveTabIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Restore keyboard focus to the active terminal after switching work tabs.
|
||||
useEffect(() => {
|
||||
if (!isTerminalLayerVisible) {
|
||||
prevActiveTabIdRef.current = activeTabId;
|
||||
return;
|
||||
}
|
||||
|
||||
const tabChanged =
|
||||
prevActiveTabIdRef.current !== undefined &&
|
||||
prevActiveTabIdRef.current !== activeTabId;
|
||||
prevActiveTabIdRef.current = activeTabId;
|
||||
|
||||
if (!tabChanged) return;
|
||||
refocusActiveTerminalSession?.();
|
||||
}, [activeTabId, isTerminalLayerVisible, refocusActiveTerminalSession]);
|
||||
|
||||
// When focusedSessionId changes or terminal layer becomes visible,
|
||||
// focus the corresponding terminal to restore :focus-within CSS state
|
||||
|
||||
@@ -43,6 +43,10 @@ export const STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE = 'netcatty_vault_proxy_
|
||||
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
|
||||
/** Inline snippet script editor height (px) in vault edit panel. */
|
||||
export const STORAGE_KEY_SNIPPET_SCRIPT_EDITOR_HEIGHT = 'netcatty_snippet_script_editor_height_v1';
|
||||
/** Terminal compose bar total height (px). */
|
||||
export const STORAGE_KEY_COMPOSE_BAR_HEIGHT = 'netcatty_compose_bar_height_v1';
|
||||
/** Snippet IDs pinned to the terminal compose bar quick strip. */
|
||||
export const STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS = 'netcatty_compose_bar_pinned_snippets_v1';
|
||||
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
|
||||
|
||||
// Update check
|
||||
|
||||
23
lib/customCss.test.ts
Normal file
23
lib/customCss.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = new URL('..', import.meta.url);
|
||||
|
||||
function readProjectFile(path: string): string {
|
||||
return readFileSync(join(root.pathname, path), 'utf8');
|
||||
}
|
||||
|
||||
test('custom CSS helper uses a single stable style element id', () => {
|
||||
const source = readProjectFile('lib/customCss.ts');
|
||||
|
||||
assert.match(source, /netcatty-custom-css/);
|
||||
assert.match(source, /styleEl\.textContent = css/);
|
||||
});
|
||||
|
||||
test('settings state applies custom CSS through the shared helper', () => {
|
||||
const source = readProjectFile('application/state/useSettingsState.ts');
|
||||
|
||||
assert.match(source, /applyCustomCssToDocument\(customCSS\)/);
|
||||
});
|
||||
14
lib/customCss.ts
Normal file
14
lib/customCss.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const CUSTOM_CSS_STYLE_ID = 'netcatty-custom-css';
|
||||
|
||||
/** Inject or update the user custom CSS style block in document.head. */
|
||||
export function applyCustomCssToDocument(css: string): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
let styleEl = document.getElementById(CUSTOM_CSS_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = CUSTOM_CSS_STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = css;
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
import { splitFontFamilyList } from '../infrastructure/config/cjkFonts';
|
||||
|
||||
const KNOWN_BUNDLED_FAMILIES = new Set<string>([
|
||||
'JetBrains Mono', // @fontsource/jetbrains-mono (regular, 500, 600)
|
||||
'JetBrains Mono', // @fontsource/jetbrains-mono (400, 500, 600)
|
||||
'Sarasa Mono SC', // public/fonts/SarasaMonoSC-Regular.woff2 (OFL)
|
||||
]);
|
||||
|
||||
|
||||
93
lib/fontWeightAvailability.test.ts
Normal file
93
lib/fontWeightAvailability.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isBoldWeightDistinctWithContext,
|
||||
pickNearestBundledWeight,
|
||||
resolveFontWeightBold,
|
||||
} from './fontWeightAvailability';
|
||||
|
||||
function makeWeightContext(weightsByFamily: Record<string, Partial<Record<number, number>>>) {
|
||||
return {
|
||||
measureText: (font: string, text: string) => {
|
||||
const match = font.match(/^(\d+)\s+\d+px\s+"?([^",]+)"?,/);
|
||||
const weight = match ? Number(match[1]) : 400;
|
||||
const family = match?.[2] ?? '';
|
||||
const width = weightsByFamily[family]?.[weight] ?? weightsByFamily[family]?.[400] ?? 100;
|
||||
return {
|
||||
width: width * text.length,
|
||||
actualBoundingBoxAscent: 10,
|
||||
actualBoundingBoxDescent: 2,
|
||||
} as TextMetrics;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('pickNearestBundledWeight', () => {
|
||||
it('returns the desired weight when bundled', () => {
|
||||
assert.equal(pickNearestBundledWeight([400, 500, 600], 600, 400), 600);
|
||||
});
|
||||
|
||||
it('falls back to the nearest heavier bundled weight', () => {
|
||||
assert.equal(pickNearestBundledWeight([400, 500, 600], 700, 400), 600);
|
||||
});
|
||||
|
||||
it('returns normal weight when nothing heavier is bundled', () => {
|
||||
assert.equal(pickNearestBundledWeight([400], 700, 400), 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBoldWeightDistinctWithContext', () => {
|
||||
it('detects a real bold face via width differences', () => {
|
||||
const ctx = makeWeightContext({
|
||||
Menlo: { 400: 10, 700: 12 },
|
||||
});
|
||||
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), true);
|
||||
});
|
||||
|
||||
it('detects a real bold face via ascent differences', () => {
|
||||
const ctx = {
|
||||
measureText: (font: string, text: string) => {
|
||||
const isBold = font.startsWith('700 ');
|
||||
return {
|
||||
width: text.length * 10,
|
||||
actualBoundingBoxAscent: isBold ? 12 : 10,
|
||||
actualBoundingBoxDescent: 2,
|
||||
} as TextMetrics;
|
||||
},
|
||||
};
|
||||
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), true);
|
||||
});
|
||||
|
||||
it('rejects unavailable bold weights that collapse to the normal face', () => {
|
||||
const ctx = makeWeightContext({
|
||||
Menlo: { 400: 10, 700: 10 },
|
||||
});
|
||||
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFontWeightBold', () => {
|
||||
it('caps bundled JetBrains Mono bold at 600 when 700 is requested', () => {
|
||||
assert.equal(
|
||||
resolveFontWeightBold({
|
||||
fontFamilyCss: '"JetBrains Mono", monospace',
|
||||
normalWeight: 400,
|
||||
desiredBoldWeight: 700,
|
||||
fontSize: 14,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns normal weight when bold is not heavier than normal', () => {
|
||||
assert.equal(
|
||||
resolveFontWeightBold({
|
||||
fontFamilyCss: '"JetBrains Mono", monospace',
|
||||
normalWeight: 600,
|
||||
desiredBoldWeight: 500,
|
||||
fontSize: 14,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
});
|
||||
});
|
||||
104
lib/fontWeightAvailability.ts
Normal file
104
lib/fontWeightAvailability.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { extractPrimaryFamily } from './fontAvailability';
|
||||
|
||||
/** Weights actually shipped via @fontsource in index.tsx. */
|
||||
export const BUNDLED_FONT_WEIGHTS: Readonly<Record<string, readonly number[]>> = {
|
||||
'JetBrains Mono': [400, 500, 600],
|
||||
};
|
||||
|
||||
export type FontWeightMeasureContext = {
|
||||
measureText: (font: string, text: string) => TextMetrics;
|
||||
};
|
||||
|
||||
const BOLD_PROBE = 'WMwm0123456789';
|
||||
|
||||
export function pickNearestBundledWeight(
|
||||
available: readonly number[],
|
||||
desired: number,
|
||||
normal: number,
|
||||
): number {
|
||||
if (available.includes(desired)) return desired;
|
||||
const heavier = available.filter((weight) => weight > normal);
|
||||
if (heavier.length === 0) return normal;
|
||||
return heavier.reduce((best, weight) =>
|
||||
Math.abs(weight - desired) < Math.abs(best - desired) ? weight : best,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when rendering `boldWeight` produces measurably different glyphs than
|
||||
* `normalWeight` for `family`. Unlike document.fonts.check(), this does not
|
||||
* false-positive on syntactically valid but unavailable families/weights in
|
||||
* Chromium (see fontAvailability.ts).
|
||||
*/
|
||||
export function isBoldWeightDistinctWithContext(
|
||||
family: string,
|
||||
normalWeight: number,
|
||||
boldWeight: number,
|
||||
fontSize: number,
|
||||
ctx: FontWeightMeasureContext,
|
||||
): boolean {
|
||||
if (boldWeight <= normalWeight) return false;
|
||||
|
||||
const quoted = /\s/.test(family) ? `"${family}"` : family;
|
||||
const normalFont = `${normalWeight} ${fontSize}px ${quoted}, monospace`;
|
||||
const boldFont = `${boldWeight} ${fontSize}px ${quoted}, monospace`;
|
||||
|
||||
const normalMetrics = ctx.measureText(normalFont, BOLD_PROBE);
|
||||
const boldMetrics = ctx.measureText(boldFont, BOLD_PROBE);
|
||||
|
||||
if (Math.abs(boldMetrics.width - normalMetrics.width) > 0.01) return true;
|
||||
|
||||
const normalAscent = normalMetrics.actualBoundingBoxAscent ?? 0;
|
||||
const boldAscent = boldMetrics.actualBoundingBoxAscent ?? 0;
|
||||
if (Math.abs(boldAscent - normalAscent) > 0.01) return true;
|
||||
|
||||
const normalDescent = normalMetrics.actualBoundingBoxDescent ?? 0;
|
||||
const boldDescent = boldMetrics.actualBoundingBoxDescent ?? 0;
|
||||
return Math.abs(boldDescent - normalDescent) > 0.01;
|
||||
}
|
||||
|
||||
function buildBrowserMeasureContext(): FontWeightMeasureContext | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
return {
|
||||
measureText: (font, text) => {
|
||||
ctx.font = font;
|
||||
return ctx.measureText(text);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the boldest weight xterm can safely rasterize for the primary font.
|
||||
* Falls back to `normalWeight` when the requested bold face is unavailable.
|
||||
*/
|
||||
export function resolveFontWeightBold(args: {
|
||||
fontFamilyCss: string;
|
||||
normalWeight: number;
|
||||
desiredBoldWeight: number;
|
||||
fontSize: number;
|
||||
}): number {
|
||||
const { fontFamilyCss, normalWeight, desiredBoldWeight, fontSize } = args;
|
||||
if (desiredBoldWeight <= normalWeight) return normalWeight;
|
||||
|
||||
const primary = extractPrimaryFamily(fontFamilyCss);
|
||||
const bundled = BUNDLED_FONT_WEIGHTS[primary];
|
||||
if (bundled) {
|
||||
return pickNearestBundledWeight(bundled, desiredBoldWeight, normalWeight);
|
||||
}
|
||||
|
||||
const ctx = buildBrowserMeasureContext();
|
||||
if (!ctx) return desiredBoldWeight;
|
||||
|
||||
return isBoldWeightDistinctWithContext(
|
||||
primary,
|
||||
normalWeight,
|
||||
desiredBoldWeight,
|
||||
fontSize,
|
||||
ctx,
|
||||
)
|
||||
? desiredBoldWeight
|
||||
: normalWeight;
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/terminalLayer/*.test.ts components/settings/tabs/ai/*.test.ts components/ai/*.test.ts components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts"
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/ai/*.test.ts components/ai/*.test.ts components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
|
||||
Reference in New Issue
Block a user