[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
318
components/terminal/HostKeywordHighlightPopover.tsx
Normal file
318
components/terminal/HostKeywordHighlightPopover.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Host Keyword Highlight Popover
|
||||
* Allows users to manage host-specific keyword highlighting rules in the terminal statusbar
|
||||
*/
|
||||
import { Highlighter, Plus, Trash2, RotateCcw } from 'lucide-react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host, KeywordHighlightRule } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
export interface HostKeywordHighlightPopoverProps {
|
||||
host?: Host;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_NEW_RULE_COLOR = '#F87171';
|
||||
|
||||
export function addHostKeywordHighlightRule(host: Host, rule: KeywordHighlightRule): Host {
|
||||
return {
|
||||
...host,
|
||||
keywordHighlightRules: [...(host.keywordHighlightRules ?? []), rule],
|
||||
keywordHighlightEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverProps> = ({
|
||||
host,
|
||||
onUpdateHost,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
buttonClassName = '',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [newRuleLabel, setNewRuleLabel] = useState('');
|
||||
const [newRulePattern, setNewRulePattern] = useState('');
|
||||
const [newRuleColor, setNewRuleColor] = useState(DEFAULT_NEW_RULE_COLOR);
|
||||
const [patternError, setPatternError] = useState<string | null>(null);
|
||||
|
||||
const rules = useMemo(() => host?.keywordHighlightRules ?? [], [host?.keywordHighlightRules]);
|
||||
const enabled = host?.keywordHighlightEnabled ?? false;
|
||||
|
||||
const updateRules = useCallback((newRules: KeywordHighlightRule[]) => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightRules: newRules });
|
||||
}, [host, onUpdateHost]);
|
||||
|
||||
const toggleEnabled = useCallback(() => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightEnabled: !enabled });
|
||||
}, [host, onUpdateHost, enabled]);
|
||||
|
||||
const validatePattern = (pattern: string): boolean => {
|
||||
try {
|
||||
new RegExp(pattern, 'gi');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRule = useCallback(() => {
|
||||
if (!newRuleLabel.trim() || !newRulePattern.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePattern(newRulePattern)) {
|
||||
setPatternError(t('terminal.toolbar.hostHighlight.invalidPattern'));
|
||||
return;
|
||||
}
|
||||
|
||||
const newRule: KeywordHighlightRule = {
|
||||
id: crypto.randomUUID(),
|
||||
label: newRuleLabel.trim(),
|
||||
patterns: [newRulePattern.trim()],
|
||||
color: newRuleColor,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
if (host && onUpdateHost) {
|
||||
onUpdateHost(addHostKeywordHighlightRule(host, newRule));
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setNewRuleLabel('');
|
||||
setNewRulePattern('');
|
||||
setNewRuleColor(DEFAULT_NEW_RULE_COLOR);
|
||||
setPatternError(null);
|
||||
}, [newRuleLabel, newRulePattern, newRuleColor, host, onUpdateHost, t]);
|
||||
|
||||
const handleDeleteRule = useCallback((ruleId: string) => {
|
||||
updateRules(rules.filter((r) => r.id !== ruleId));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleColorChange = useCallback((ruleId: string, color: string) => {
|
||||
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, color } : r)));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleToggleRule = useCallback((ruleId: string) => {
|
||||
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, enabled: !r.enabled } : r)));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightRules: [], keywordHighlightEnabled: false });
|
||||
}, [host, onUpdateHost]);
|
||||
|
||||
const handlePatternChange = (value: string) => {
|
||||
setNewRulePattern(value);
|
||||
if (patternError && validatePattern(value)) {
|
||||
setPatternError(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Disable if no host (local/serial terminal sessions)
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
|
||||
const isDisabled = !host || !onUpdateHost || isLocalTerminal || isSerialTerminal;
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
{/* Force-close tooltip while the panel is open so the blue label does not
|
||||
sit on top of the trigger/panel (especially in the compact top-right
|
||||
cluster when the host info bar is hidden). */}
|
||||
<Tooltip open={isOpen ? false : undefined}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
aria-label={t('terminal.toolbar.hostHighlight.title')}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Highlighter size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{/* Toolbar sits at the top of the terminal pane; open below the trigger
|
||||
so the label is not clipped by the window/title edge (especially in
|
||||
compact top-right action cluster when the host info bar is hidden). */}
|
||||
<TooltipContent side="bottom" sideOffset={8} className="whitespace-nowrap max-w-none">
|
||||
{t('terminal.toolbar.hostHighlight.title')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-80 p-0" align="end" side="bottom" sideOffset={8} collisionPadding={12}>
|
||||
<div className="px-3 py-2 border-b bg-muted/30 flex items-center justify-between gap-2 min-w-0">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground truncate min-w-0">
|
||||
{t('terminal.toolbar.hostHighlight.title')}
|
||||
</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={toggleEnabled}
|
||||
className={`
|
||||
relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
|
||||
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
|
||||
${enabled ? 'bg-primary' : 'bg-muted-foreground/30'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0
|
||||
transition duration-200 ease-in-out
|
||||
${enabled ? 'translate-x-4' : 'translate-x-0'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="p-2 space-y-1.5">
|
||||
{rules.length === 0 ? (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground text-center italic">
|
||||
{t('terminal.toolbar.hostHighlight.noRules')}
|
||||
</div>
|
||||
) : (
|
||||
rules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent/50 group"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRule(rule.id)}
|
||||
className={`
|
||||
flex-shrink-0 w-3 h-3 rounded-sm border transition-colors
|
||||
${rule.enabled
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-transparent border-muted-foreground/50'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{rule.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
style={{ color: rule.enabled ? rule.color : 'inherit' }}
|
||||
>
|
||||
{rule.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate">
|
||||
{rule.patterns.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.color}
|
||||
onChange={(e) => handleColorChange(rule.id, e.target.value)}
|
||||
className="sr-only"
|
||||
aria-label={`${t('terminal.toolbar.hostHighlight.changeColor')} ${rule.label}`}
|
||||
/>
|
||||
<span
|
||||
className="block w-6 h-4 rounded cursor-pointer border border-border/50 hover:border-border"
|
||||
style={{ backgroundColor: rule.color }}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add new rule form */}
|
||||
<div className="p-2 border-t bg-muted/20 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('terminal.toolbar.hostHighlight.addRule')}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
placeholder={t('terminal.toolbar.hostHighlight.labelPlaceholder')}
|
||||
value={newRuleLabel}
|
||||
onChange={(e) => setNewRuleLabel(e.target.value)}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={newRuleColor}
|
||||
onChange={(e) => setNewRuleColor(e.target.value)}
|
||||
className="sr-only"
|
||||
aria-label={t('terminal.toolbar.hostHighlight.selectColor')}
|
||||
/>
|
||||
<span
|
||||
className="block w-7 h-7 rounded cursor-pointer border border-border/50 hover:border-border"
|
||||
style={{ backgroundColor: newRuleColor }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
placeholder={t('terminal.toolbar.hostHighlight.patternPlaceholder')}
|
||||
value={newRulePattern}
|
||||
onChange={(e) => handlePatternChange(e.target.value)}
|
||||
className={`h-7 text-xs font-mono flex-1 ${patternError ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={handleAddRule}
|
||||
disabled={!newRuleLabel.trim() || !newRulePattern.trim()}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
{patternError && (
|
||||
<div className="text-[10px] text-destructive">{patternError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
{rules.length > 0 && (
|
||||
<div className="p-2 border-t flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
<RotateCcw size={10} className="mr-1" />
|
||||
{t('terminal.toolbar.hostHighlight.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostKeywordHighlightPopover;
|
||||
Reference in New Issue
Block a user