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
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
/**
|
|
* 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;
|