[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:
314
components/settings/tabs/SettingsTerminalTabControls.tsx
Normal file
314
components/settings/tabs/SettingsTerminalTabControls.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ChevronRight, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||
|
||||
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES, type KeywordHighlightRule } from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Toggle } from "../settings-ui";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { Textarea } from "../../ui/textarea";
|
||||
|
||||
// Keyword highlight rules editor for global settings
|
||||
const DEFAULT_NEW_RULE_COLOR = '#F87171';
|
||||
|
||||
/** Temporarily disable/enable one rule without deleting it. */
|
||||
export function toggleKeywordHighlightRuleEnabled(
|
||||
rules: KeywordHighlightRule[],
|
||||
ruleId: string,
|
||||
): KeywordHighlightRule[] {
|
||||
return rules.map((rule) => (
|
||||
rule.id === ruleId ? { ...rule, enabled: !rule.enabled } : rule
|
||||
));
|
||||
}
|
||||
|
||||
export const AddCustomRuleDialog: React.FC<{
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editRule?: KeywordHighlightRule | null;
|
||||
isBuiltIn?: boolean;
|
||||
onAdd: (rule: KeywordHighlightRule) => void;
|
||||
}> = ({ open, onOpenChange, editRule, isBuiltIn = false, onAdd }) => {
|
||||
const { t } = useI18n();
|
||||
const [label, setLabel] = useState('');
|
||||
// Multi-line text: one regex pattern per line. Built-in rules typically
|
||||
// ship multiple patterns (e.g. several spellings of "error"), and the user
|
||||
// is allowed to add as many as they like.
|
||||
const [patternsText, setPatternsText] = useState('');
|
||||
const [color, setColor] = useState(DEFAULT_NEW_RULE_COLOR);
|
||||
const [patternError, setPatternError] = useState<string | null>(null);
|
||||
|
||||
const reset = () => { setLabel(''); setPatternsText(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (open && editRule) {
|
||||
setLabel(editRule.label);
|
||||
setPatternsText(editRule.patterns.join('\n'));
|
||||
setColor(editRule.color);
|
||||
setPatternError(null);
|
||||
} else if (!open) {
|
||||
reset();
|
||||
}
|
||||
}, [open, editRule]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!label.trim()) return;
|
||||
const patterns = patternsText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
if (patterns.length === 0) return;
|
||||
for (const p of patterns) {
|
||||
try { new RegExp(p, 'gi'); } catch {
|
||||
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onAdd({
|
||||
id: editRule?.id ?? crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
patterns,
|
||||
color,
|
||||
enabled: editRule?.enabled ?? true,
|
||||
// Editing a built-in rule flips it into "user-customized" mode so the
|
||||
// normalizer keeps the user's patterns across restarts.
|
||||
customized: isBuiltIn ? true : editRule?.customized,
|
||||
});
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const dialogTitleKey = editRule
|
||||
? (isBuiltIn
|
||||
? 'settings.terminal.keywordHighlight.editBuiltIn'
|
||||
: 'settings.terminal.keywordHighlight.editCustom')
|
||||
: 'settings.terminal.keywordHighlight.addCustom';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(dialogTitleKey)}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('settings.terminal.keywordHighlight.labelField')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('settings.terminal.keywordHighlight.labelPlaceholder')}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="sr-only" />
|
||||
<span className="block w-9 h-9 rounded-md cursor-pointer border border-border/50 hover:border-border" style={{ backgroundColor: color }} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('settings.terminal.keywordHighlight.patternField')}</Label>
|
||||
<Textarea
|
||||
placeholder={t('settings.terminal.keywordHighlight.patternPlaceholder')}
|
||||
value={patternsText}
|
||||
onChange={(e) => { setPatternsText(e.target.value); if (patternError) setPatternError(null); }}
|
||||
rows={Math.max(3, Math.min(10, patternsText.split('\n').length + 1))}
|
||||
className={cn("font-mono text-xs", patternError && "border-destructive")}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('settings.terminal.keywordHighlight.patternHint')}
|
||||
</p>
|
||||
{patternError && <div className="text-xs text-destructive">{patternError}</div>}
|
||||
</div>
|
||||
{label.trim() && patternsText.trim() && !patternError && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">{t('settings.terminal.keywordHighlight.preview')}:</span>
|
||||
<span className="text-sm font-medium" style={{ color }}>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!label.trim() || !patternsText.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const KeywordHighlightRulesEditor: React.FC<{
|
||||
rules: KeywordHighlightRule[];
|
||||
onChange: (rules: KeywordHighlightRule[]) => void;
|
||||
}> = ({ rules, onChange }) => {
|
||||
const { t } = useI18n();
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<KeywordHighlightRule | null>(null);
|
||||
|
||||
const isBuiltIn = (id: string) => DEFAULT_KEYWORD_HIGHLIGHT_RULES.some((r) => r.id === id);
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{rules.map((rule) => {
|
||||
const builtIn = isBuiltIn(rule.id);
|
||||
const customized = builtIn && rule.customized;
|
||||
return (
|
||||
<div key={rule.id} className="flex items-center gap-2 group">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<span className={cn("text-sm truncate", !rule.enabled && "text-muted-foreground line-through")} style={rule.enabled ? { color: rule.color } : undefined}>
|
||||
{rule.label}
|
||||
</span>
|
||||
<Pencil
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
|
||||
/>
|
||||
{!builtIn && (
|
||||
<Trash2
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-destructive"
|
||||
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
|
||||
/>
|
||||
)}
|
||||
{customized && (
|
||||
<RotateCcw
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
aria-label={t('settings.terminal.keywordHighlight.resetBuiltIn')}
|
||||
onClick={() => {
|
||||
// Drop the user's customizations and restore the shipped
|
||||
// defaults for label/patterns. Color stays whatever the
|
||||
// user picked (color is the only built-in property they
|
||||
// can edit without flipping `customized`).
|
||||
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
|
||||
if (!def) return;
|
||||
onChange(rules.map((r) => r.id === rule.id
|
||||
? { ...def, color: r.color, enabled: r.enabled, customized: false }
|
||||
: r));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.color}
|
||||
onChange={(e) => onChange(rules.map((r) => r.id === rule.id ? { ...r, color: e.target.value } : r))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className="block w-8 h-5 rounded cursor-pointer border border-border/50 hover:border-border transition-colors"
|
||||
style={{ backgroundColor: rule.color }}
|
||||
/>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={rule.enabled}
|
||||
onChange={() => onChange(toggleKeywordHighlightRuleEnabled(rules, rule.id))}
|
||||
ariaLabel={`${rule.label}, ${rule.enabled ? t('common.enabled') : t('common.disabled')}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex pt-2 mt-2 border-t border-border/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
>
|
||||
<Plus size={14} className="mr-1.5" />
|
||||
{t('settings.terminal.keywordHighlight.addCustom')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
// Restore every built-in rule back to shipped defaults
|
||||
// (label/patterns/color), drop customizations, and keep the user's
|
||||
// custom rules untouched.
|
||||
onChange(rules.map((rule) => {
|
||||
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
|
||||
if (!def) return rule;
|
||||
return { ...def, enabled: rule.enabled, customized: false };
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t("settings.terminal.keywordHighlight.resetDefaults")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddCustomRuleDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={(v) => { setAddDialogOpen(v); if (!v) setEditingRule(null); }}
|
||||
editRule={editingRule}
|
||||
isBuiltIn={editingRule ? isBuiltIn(editingRule.id) : false}
|
||||
onAdd={(rule) => {
|
||||
if (editingRule) {
|
||||
onChange(rules.map((r) => r.id === editingRule.id ? rule : r));
|
||||
} else {
|
||||
onChange([...rules, rule]);
|
||||
}
|
||||
setEditingRule(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Theme preview button component
|
||||
export const ThemePreviewButton: React.FC<{
|
||||
theme: (typeof TERMINAL_THEMES)[0];
|
||||
onClick?: () => void;
|
||||
buttonLabel: string;
|
||||
disabled?: boolean;
|
||||
}> = ({ theme, onClick, buttonLabel, disabled = false }) => {
|
||||
const c = theme.colors;
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-4 p-3 rounded-lg border bg-card transition-all text-left",
|
||||
disabled ? "cursor-default" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{/* Theme preview swatch */}
|
||||
<div
|
||||
className="w-20 h-14 rounded-lg flex-shrink-0 flex flex-col justify-center items-start pl-2 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: c.background }}
|
||||
>
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
|
||||
<span className="font-mono text-[8px]" style={{ color: c.blue }}>ls</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: c.cyan }} />
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: c.magenta }} />
|
||||
</div>
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
|
||||
<span className="inline-block w-1.5 h-2 animate-pulse" style={{ backgroundColor: c.cursor }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{theme.name}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
|
||||
{/* Action button area */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-xs">{buttonLabel}</span>
|
||||
{!disabled && <ChevronRight size={16} />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user