Compare commits

...

10 Commits

Author SHA1 Message Date
陈大猫
031bf0ee45 Merge pull request #1188 from binaricat/codex/vault-sidebar-spacing
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
[codex] Balance vault sidebar spacing
2026-06-02 01:29:14 +08:00
bincxz
0efe80b06d Balance vault sidebar spacing 2026-06-02 01:28:39 +08:00
陈大猫
3fb7c6dd21 Merge pull request #1183 from pplulee/feature/codesnip-edit
feat: 优化代码片段脚本编辑
2026-06-02 01:17:36 +08:00
bincxz
c7e4ac82ca Fix snippet editor focus outline 2026-06-02 01:15:36 +08:00
bincxz
d5e29598d3 Merge main into PR 1183 and address review 2026-06-02 01:07:04 +08:00
陈大猫
fca7782634 Remove vault content left margin (#1187) 2026-06-02 01:00:19 +08:00
Pyro
42b23a9faa fix: blurry inline aside panel rendering (#1185) 2026-06-02 00:40:17 +08:00
Pyro
06011d01d6 fix: update RU localization (#1184) 2026-06-01 23:31:03 +08:00
pplulee
4bf4e65df8 fix(keychain): enhance key management UI with copy, edit, and delete actions (#1182) 2026-06-01 23:13:31 +08:00
pplulee
45e62ed43e feat(snippets): add SnippetScriptEditor component and update usage in dialogs 2026-06-01 23:05:56 +08:00
12 changed files with 294 additions and 47 deletions

View File

@@ -514,6 +514,9 @@ export const enTerminalMessages: Messages = {
'snippets.field.packagePlaceholder': 'Select or create package',
'snippets.field.createPackage': 'Create Package',
'snippets.field.scriptRequired': 'Script *',
'snippets.scriptEditor.expand': 'Open in dialog',
'snippets.scriptEditor.resize': 'Resize editor height',
'snippets.scriptEditor.modalTitle': 'Edit script',
'snippets.targets.title': 'Targets',
'snippets.targets.add': 'Add targets',
'snippets.history.title': 'Shell History',

View File

@@ -532,6 +532,20 @@ export const ruTerminalMessages: Messages = {
'snippets.field.packagePlaceholder': 'Выберите или создайте пакет',
'snippets.field.createPackage': 'Создать пакет',
'snippets.field.scriptRequired': 'Скрипт *',
'snippets.scriptEditor.expand': 'Открыть в окне',
'snippets.scriptEditor.resize': 'Изменить высоту редактора',
'snippets.scriptEditor.modalTitle': 'Редактировать скрипт',
'snippets.variables.dialogTitle': 'Переменные сниппета',
'snippets.variables.dialogDesc': 'Заполните значения для "{label}" перед запуском.',
'snippets.variables.hint': 'Значения вставляются в скрипт как есть (без shell-экранирования).',
'snippets.variables.preview': 'Предпросмотр',
'snippets.variables.placeholder': 'Введите значение',
'snippets.variables.placeholderDefault': 'По умолчанию: {value}',
'snippets.variables.required': 'Эта переменная обязательна',
'snippets.variables.run': 'Запустить',
'snippets.field.variablesHelp': 'Используйте {{name}} или {{name:default}} для плейсхолдеров в скрипте.',
'snippets.field.variablesDetected': 'Переменные',
'snippets.field.variableDefault': 'по умолчанию {value}',
'snippets.targets.title': 'Цели',
'snippets.targets.add': 'Добавить цели',
'snippets.history.title': 'История оболочки',

View File

@@ -495,6 +495,9 @@ export const zhCNTerminalMessages: Messages = {
'snippets.field.packagePlaceholder': '选择或创建代码包',
'snippets.field.createPackage': '创建代码包',
'snippets.field.scriptRequired': '脚本 *',
'snippets.scriptEditor.expand': '弹窗编辑',
'snippets.scriptEditor.resize': '调整编辑器高度',
'snippets.scriptEditor.modalTitle': '编辑脚本',
'snippets.targets.title': '目标主机',
'snippets.targets.add': '添加目标主机',
'snippets.history.title': 'Shell 历史',

View File

@@ -1,11 +1,12 @@
import {
BadgeCheck,
ChevronDown,
Copy,
Edit2,
ExternalLink,
Key,
LayoutGrid,
List as ListIcon,
MoreHorizontal,
Plus,
Shield,
Trash2,
@@ -838,9 +839,35 @@ echo $3 >> "$FILE"`);
</AsideActionMenuItem>
</AsideActionMenu>
) : panel.type === "view" ? (
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal size={16} />
</Button>
<AsideActionMenu>
{panel.key.publicKey ? (
<AsideActionMenuItem
icon={<Copy size={14} />}
onClick={() => copyPublicKey(panel.key)}
>
{t("action.copyPublicKey")}
</AsideActionMenuItem>
) : null}
<AsideActionMenuItem
icon={<ExternalLink size={14} />}
onClick={() => openKeyExport(panel.key)}
>
{t("action.keyExport")}
</AsideActionMenuItem>
<AsideActionMenuItem
icon={<Edit2 size={14} />}
onClick={() => openKeyEdit(panel.key)}
>
{t("action.edit")}
</AsideActionMenuItem>
<AsideActionMenuItem
variant="destructive"
icon={<Trash2 size={14} />}
onClick={() => handleDelete(panel.key.id)}
>
{t("action.delete")}
</AsideActionMenuItem>
</AsideActionMenu>
) : undefined
}
>

View File

@@ -24,7 +24,7 @@ import {
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { CodeTextarea } from './ui/code-textarea';
import { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
export interface QuickAddSnippetDialogProps {
snippets: Snippet[];
@@ -148,8 +148,11 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogContent
className="max-w-md max-h-[min(90vh,720px)] flex flex-col overflow-hidden"
onKeyDown={handleKeyDown}
>
<DialogHeader className="shrink-0">
<DialogTitle>
{t(editing ? 'snippets.panel.editTitle' : 'snippets.panel.newTitle')}
</DialogTitle>
@@ -158,7 +161,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="min-h-0 space-y-3 overflow-y-auto pr-1">
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-label" className="text-xs">
{t('snippets.field.description')}
@@ -174,18 +177,13 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-command" className="text-xs">
{t('snippets.field.scriptRequired')}
</Label>
<CodeTextarea
id="quick-add-snippet-command"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="echo hello"
className="min-h-[120px]"
/>
</div>
<SnippetScriptEditor
id="quick-add-snippet-command"
label={t('snippets.field.scriptRequired')}
value={command}
onChange={setCommand}
placeholder="echo hello"
/>
<div className="space-y-1.5">
<Label className="text-xs flex items-center gap-1.5">
@@ -203,7 +201,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
</div>
</div>
<DialogFooter>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>

View File

@@ -7,7 +7,7 @@ import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-pane
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Input } from './ui/input';
import { CodeTextarea } from './ui/code-textarea';
import { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Combobox } from './ui/combobox';
import { DistroAvatar } from './DistroAvatar';
@@ -165,12 +165,11 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
{/* Script */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.scriptRequired')}</p>
<CodeTextarea
<SnippetScriptEditor
label={t('snippets.field.scriptRequired')}
placeholder="ls -l"
className="min-h-[120px]"
value={editingSnippet.command || ''}
onChange={(e) => setEditingSnippet({ ...editingSnippet, command: e.target.value })}
onChange={(command) => setEditingSnippet({ ...editingSnippet, command })}
/>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('snippets.field.variablesHelp')}

View File

@@ -2,12 +2,13 @@
* View Key Panel - Display SSH key details
*/
import { Copy,Info } from 'lucide-react';
import React from 'react';
import { Check, Copy, Info } from 'lucide-react';
import React, { useCallback, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Label } from '../ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { copyToClipboard } from './utils';
interface ViewKeyPanelProps {
@@ -20,6 +21,15 @@ export const ViewKeyPanel: React.FC<ViewKeyPanelProps> = ({
onExport,
}) => {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const handleCopyPublicKey = useCallback(async () => {
const ok = await copyToClipboard(keyItem.publicKey || '');
if (!ok) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [keyItem.publicKey]);
return (
<>
<div className="space-y-2">
@@ -30,18 +40,34 @@ export const ViewKeyPanel: React.FC<ViewKeyPanelProps> = ({
{keyItem.publicKey && (
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.field.publicKey')}</Label>
<div className="relative">
<div className="p-3 bg-card border border-border/80 rounded-lg font-mono text-xs break-all max-h-32 overflow-y-auto">
<div className="flex rounded-lg border border-border/80 bg-card overflow-hidden">
<div className="flex-1 min-w-0 p-3 font-mono text-xs break-all max-h-32 overflow-y-auto">
{keyItem.publicKey}
</div>
<Button
size="icon"
variant="ghost"
className="absolute top-2 right-2 h-7 w-7"
onClick={() => copyToClipboard(keyItem.publicKey || '')}
>
<Copy size={12} />
</Button>
<div className="shrink-0 flex flex-col border-l border-border/60 p-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => void handleCopyPublicKey()}
aria-label={
copied
? t('cloudSync.githubFlow.copied')
: t('action.copyPublicKey')
}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{copied
? t('cloudSync.githubFlow.copied')
: t('action.copyPublicKey')}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,174 @@
import { Maximize2 } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { STORAGE_KEY_SNIPPET_SCRIPT_EDITOR_HEIGHT } from '@/infrastructure/config/storageKeys.ts';
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter.ts';
import { cn } from '@/lib/utils.ts';
import { Button } from '../ui/button';
import { CodeTextarea } from '../ui/code-textarea';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
const DEFAULT_HEIGHT = 120;
const MIN_HEIGHT = 80;
const MAX_HEIGHT = 520;
function clampHeight(height: number): number {
return Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, height));
}
function readStoredHeight(): number {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SNIPPET_SCRIPT_EDITOR_HEIGHT);
if (stored === null) return DEFAULT_HEIGHT;
return clampHeight(stored);
}
const editorFillClass =
'[&>div]:h-full [&_textarea]:h-full [&_textarea]:min-h-0 [&_textarea]:overflow-auto';
export interface SnippetScriptEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
id?: string;
/** Shown on the same row as the expand button (e.g. "Script *"). */
label?: string;
}
export const SnippetScriptEditor: React.FC<SnippetScriptEditorProps> = ({
value,
onChange,
placeholder,
id,
label,
}) => {
const { t } = useI18n();
const [height, setHeight] = useState(readStoredHeight);
const [modalOpen, setModalOpen] = useState(false);
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
const heightRef = useRef(height);
heightRef.current = height;
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startY: e.clientY, startHeight: heightRef.current };
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
}, []);
useEffect(() => {
const onMove = (e: MouseEvent) => {
if (!dragRef.current) return;
const delta = e.clientY - dragRef.current.startY;
setHeight(clampHeight(dragRef.current.startHeight + delta));
};
const onUp = () => {
if (dragRef.current) {
localStorageAdapter.writeNumber(
STORAGE_KEY_SNIPPET_SCRIPT_EDITOR_HEIGHT,
heightRef.current,
);
}
dragRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, []);
return (
<>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2 min-h-7">
{label ? (
id ? (
<label
htmlFor={id}
className="text-xs font-semibold text-muted-foreground shrink-0 cursor-text"
>
{label}
</label>
) : (
<p className="text-xs font-semibold text-muted-foreground shrink-0">{label}</p>
)
) : (
<span className="flex-1" aria-hidden />
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setModalOpen(true)}
aria-label={t('snippets.scriptEditor.expand')}
>
<Maximize2 size={14} />
{t('snippets.scriptEditor.expand')}
</Button>
</TooltipTrigger>
<TooltipContent>{t('snippets.scriptEditor.expand')}</TooltipContent>
</Tooltip>
</div>
<div className="relative rounded-md" style={{ height }}>
<div className={cn('h-full min-h-0 overflow-hidden', editorFillClass)}>
<CodeTextarea
id={id}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="min-h-0"
wrapperClassName="h-full"
/>
</div>
<div
role="separator"
aria-orientation="horizontal"
aria-label={t('snippets.scriptEditor.resize')}
className="absolute bottom-0 left-0 right-0 z-10 flex h-2.5 cursor-ns-resize items-center justify-center rounded-b-md hover:bg-muted/40"
onMouseDown={handleResizeStart}
>
<div className="h-0.5 w-10 rounded-full bg-border/80" />
</div>
</div>
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-4xl w-[min(90vw,56rem)] h-[min(85vh,640px)] flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-3 shrink-0">
<DialogTitle>{t('snippets.scriptEditor.modalTitle')}</DialogTitle>
</DialogHeader>
<div className={cn('flex-1 min-h-0 px-6 pb-3 overflow-hidden', editorFillClass)}>
<CodeTextarea
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="min-h-0"
wrapperClassName="h-full"
autoFocus
/>
</div>
<DialogFooter className="px-6 pb-6 pt-2 shrink-0">
<Button type="button" onClick={() => setModalOpen(false)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -4,6 +4,8 @@ import { cn } from "@/lib/utils.ts";
export interface CodeTextareaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "spellCheck"> {
showLineNumbers?: boolean;
/** Applied to the outer bordered container when line numbers are shown. */
wrapperClassName?: string;
}
function countLines(value: string): number {
@@ -12,7 +14,7 @@ function countLines(value: string): number {
}
const CodeTextarea = React.forwardRef<HTMLTextAreaElement, CodeTextareaProps>(
({ className, value, showLineNumbers = true, onScroll, ...props }, ref) => {
({ className, wrapperClassName, value, showLineNumbers = true, onScroll, ...props }, ref) => {
const gutterRef = React.useRef<HTMLDivElement>(null);
const text = typeof value === "string" ? value : String(value ?? "");
const lineCount = countLines(text);
@@ -61,8 +63,9 @@ const CodeTextarea = React.forwardRef<HTMLTextAreaElement, CodeTextareaProps>(
return (
<div
className={cn(
"flex w-full overflow-hidden rounded-md border border-input bg-background",
"focus-within:ring-1 focus-within:ring-ring",
"flex w-full overflow-hidden rounded-md border border-input bg-background transition-colors",
"focus-within:border-ring",
wrapperClassName,
)}
>
<div

View File

@@ -90,7 +90,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</Tooltip>
</div>
<div className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-3")}>
<div className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
@@ -228,7 +228,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</Tooltip>
</div>
<div className={cn("mt-auto pb-4 space-y-2", sidebarCollapsed ? "px-1.5" : "px-3")}>
<div className={cn("mt-auto pb-4 space-y-2", sidebarCollapsed ? "px-1.5" : "px-2.5")}>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -261,7 +261,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</div>
</TooltipProvider>
<div className="flex min-w-0 flex-1 p-2 pl-1" data-section="vault-stage">
<div className="flex min-w-0 flex-1 p-2 pl-0" data-section="vault-stage">
<div
className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
data-section="vault-surface"

View File

@@ -159,7 +159,6 @@
width: 0;
min-width: 0;
opacity: 0;
transform: translateX(22px);
}
55% {
opacity: 0.88;
@@ -168,13 +167,12 @@
width: var(--aside-inline-width);
min-width: var(--aside-inline-width);
opacity: 1;
transform: translateX(0);
}
}
.split-panel-enter {
animation: split-panel-enter 220ms cubic-bezier(0.24, 0.84, 0.32, 1) both;
will-change: width, opacity, transform;
will-change: width, opacity;
}
:root {

View File

@@ -41,6 +41,8 @@ export const STORAGE_KEY_VAULT_SIDEBAR_WIDTH = 'netcatty_vault_sidebar_width_v1'
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
export const STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE = 'netcatty_vault_proxy_profiles_view_mode_v1';
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';
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
// Update check