import React from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; import { openExternalUrl } from '@/lib/url'; import { isVSCodeRuntime } from '@/lib/desktop'; import { selectMcpServersForDirectory, useMcpConfigStore, envRecordToArray, type McpDraft, type McpScope, } from '@/stores/useMcpConfigStore'; import { useShallow } from 'zustand/react/shallow'; import { parseImportedMcpSnippet, applyImportedMcpToDraft, } from './mcpImport'; import { useMcpStore } from '@/stores/useMcpStore'; import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore'; import { useSettingsDirectory } from '@/hooks/useSettingsDirectory'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { cn } from '@/lib/utils'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; import { SettingsSection, SettingsFieldRow, SettingsCheckboxRow, SettingsGroupTitle, SettingsStackedField, SETTINGS_SELECT_SIZE, SETTINGS_FIELD_LABEL_CLASS, } from '@/components/sections/shared/SettingsSection'; import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint'; import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth'; import { buildMcpAuthorizationRedirectUri, startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, } from '@/components/ui/select'; import { Icon } from "@/components/icon/Icon"; import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; import { useI18n } from '@/lib/i18n'; // ───────────────────────────────────────────────────────────── // CommandTextarea — one arg per line, paste-friendly // ───────────────────────────────────────────────────────────── interface CommandTextareaProps { value: string[]; onChange: (v: string[]) => void; preview: (count: number) => string; /** Called when the text is plainly a link rather than a command. */ onDetectUrl?: (url: string) => void; } /** * Splits a shell-like command string into argv array. * Handles simple quoted args (single/double) and plain tokens. */ function parseShellCommand(raw: string): string[] { const args: string[] = []; let current = ''; let inSingle = false; let inDouble = false; for (let i = 0; i < raw.length; i++) { const ch = raw[i]; if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } if ((ch === ' ' || ch === '\t') && !inSingle && !inDouble) { if (current) { args.push(current); current = ''; } continue; } current += ch; } if (current) args.push(current); return args; } function extractAuthorizationResponse(raw: string): { code: string | null; context: { name: string; directory: string | null } | null; stateKey: string | null; } { const trimmed = raw.trim(); if (!trimmed) { return { code: null, context: null, stateKey: null }; } try { const parsed = new URL(trimmed); const code = parsed.searchParams.get('code'); if (typeof code === 'string' && code.trim()) { return { code: code.trim(), context: parseMcpOAuthCallbackContext(parsed.searchParams), stateKey: parseMcpOAuthCallbackStateKey(parsed.searchParams), }; } } catch { // Fall through to treating the pasted value as a raw authorization code. } return { code: trimmed, context: null, stateKey: null, }; } const CommandTextarea: React.FC = ({ value, onChange, preview, onDetectUrl, }) => { // Internal: one arg per line const [text, setText] = React.useState(() => value.join('\n')); // Sync when external value changes (e.g. switching servers) const prevValueRef = React.useRef(value); React.useEffect(() => { if (JSON.stringify(prevValueRef.current) !== JSON.stringify(value)) { prevValueRef.current = value; setText(value.join('\n')); } }, [value]); const commit = (raw: string) => { const lines = raw.split('\n').filter((l) => l.trim().length > 0); // A single line that is nothing but a URL is a hosted server, not a // command to run — the page switches kind rather than making the user say. if (onDetectUrl && lines.length === 1 && /^https?:\/\/\S+$/i.test(lines[0].trim())) { onDetectUrl(lines[0].trim()); return; } onChange(lines); }; /** * Pasting a whole command line splits it into arguments here, in the field * the user pasted into. The old approach — a button that read the clipboard * itself — fails outright wherever the runtime denies clipboard reads. */ const handlePaste = (event: React.ClipboardEvent) => { const raw = event.clipboardData.getData('text'); const trimmed = raw.trim(); // Only take over a paste that replaces the whole field with one command // line; anything else is ordinary editing and belongs to the browser. if (!trimmed || trimmed.includes('\n') || !/\s/.test(trimmed)) return; const target = event.currentTarget; if (target.selectionStart !== 0 || target.selectionEnd !== target.value.length) return; event.preventDefault(); const lines = parseShellCommand(trimmed); setText(lines.join('\n')); onChange(lines); }; return (