[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View 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;