add extensions settings page and context panel widget (#8540)
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
use serde_json::Value;
|
||||
use tauri::State;
|
||||
|
||||
use crate::services::goose_config::GooseConfig;
|
||||
|
||||
fn yaml_to_json(yaml: serde_yaml::Value) -> Value {
|
||||
match yaml {
|
||||
serde_yaml::Value::Null => Value::Null,
|
||||
serde_yaml::Value::Bool(b) => Value::Bool(b),
|
||||
serde_yaml::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Value::Number(i.into())
|
||||
} else if let Some(u) = n.as_u64() {
|
||||
Value::Number(u.into())
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
serde_json::Number::from_f64(f)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null)
|
||||
} else {
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
serde_yaml::Value::String(s) => Value::String(s),
|
||||
serde_yaml::Value::Sequence(seq) => {
|
||||
Value::Array(seq.into_iter().map(yaml_to_json).collect())
|
||||
}
|
||||
serde_yaml::Value::Mapping(map) => {
|
||||
let obj = map
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let key = match k {
|
||||
serde_yaml::Value::String(s) => s,
|
||||
other => serde_yaml::to_string(&other).ok()?.trim().to_string(),
|
||||
};
|
||||
Some((key, yaml_to_json(v)))
|
||||
})
|
||||
.collect();
|
||||
Value::Object(obj)
|
||||
}
|
||||
serde_yaml::Value::Tagged(tagged) => yaml_to_json(tagged.value),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_yaml(json: Value) -> serde_yaml::Value {
|
||||
match json {
|
||||
Value::Null => serde_yaml::Value::Null,
|
||||
Value::Bool(b) => serde_yaml::Value::Bool(b),
|
||||
Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
serde_yaml::Value::Number(i.into())
|
||||
} else if let Some(u) = n.as_u64() {
|
||||
serde_yaml::Value::Number(u.into())
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
serde_yaml::Value::Number(f.into())
|
||||
} else {
|
||||
serde_yaml::Value::Null
|
||||
}
|
||||
}
|
||||
Value::String(s) => serde_yaml::Value::String(s),
|
||||
Value::Array(arr) => {
|
||||
serde_yaml::Value::Sequence(arr.into_iter().map(json_to_yaml).collect())
|
||||
}
|
||||
Value::Object(obj) => {
|
||||
let mut map = serde_yaml::Mapping::new();
|
||||
for (k, v) in obj {
|
||||
map.insert(serde_yaml::Value::String(k), json_to_yaml(v));
|
||||
}
|
||||
serde_yaml::Value::Mapping(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn name_to_key(name: &str) -> String {
|
||||
let mut result = String::with_capacity(name.len());
|
||||
for c in name.chars() {
|
||||
match c {
|
||||
c if c.is_ascii_alphanumeric() || c == '_' || c == '-' => result.push(c),
|
||||
c if c.is_whitespace() => continue,
|
||||
_ => result.push('_'),
|
||||
}
|
||||
}
|
||||
result.to_lowercase()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_extensions(config: State<'_, GooseConfig>) -> Result<Vec<Value>, String> {
|
||||
let raw = config.get_extensions_raw();
|
||||
let mut entries = Vec::with_capacity(raw.len());
|
||||
|
||||
for (k, v) in raw {
|
||||
let key = match k {
|
||||
serde_yaml::Value::String(s) => s,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let mut json = yaml_to_json(v);
|
||||
|
||||
if let Value::Object(ref mut obj) = json {
|
||||
if !obj.contains_key("type") {
|
||||
continue;
|
||||
}
|
||||
obj.insert("config_key".to_string(), Value::String(key.clone()));
|
||||
obj.entry("name".to_string())
|
||||
.or_insert_with(|| Value::String(key));
|
||||
obj.entry("enabled".to_string())
|
||||
.or_insert(Value::Bool(false));
|
||||
entries.push(json);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_extension(
|
||||
name: String,
|
||||
extension_config: Value,
|
||||
enabled: bool,
|
||||
config: State<'_, GooseConfig>,
|
||||
) -> Result<(), String> {
|
||||
let key = name_to_key(&name);
|
||||
let mut raw = config.get_extensions_raw();
|
||||
|
||||
let mut entry = match extension_config {
|
||||
Value::Object(obj) => obj,
|
||||
_ => return Err("extension_config must be a JSON object".to_string()),
|
||||
};
|
||||
|
||||
entry.insert("enabled".to_string(), Value::Bool(enabled));
|
||||
entry.insert("name".to_string(), Value::String(name));
|
||||
|
||||
let yaml_value = json_to_yaml(Value::Object(entry));
|
||||
raw.insert(serde_yaml::Value::String(key), yaml_value);
|
||||
|
||||
config.set_extensions_raw(raw)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_extension(config_key: String, config: State<'_, GooseConfig>) -> Result<(), String> {
|
||||
let mut raw = config.get_extensions_raw();
|
||||
let yaml_key = serde_yaml::Value::String(config_key.clone());
|
||||
if raw.remove(&yaml_key).is_none() {
|
||||
return Err(format!("Extension '{}' not found", config_key));
|
||||
}
|
||||
config.set_extensions_raw(raw)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn toggle_extension(
|
||||
config_key: String,
|
||||
enabled: bool,
|
||||
config: State<'_, GooseConfig>,
|
||||
) -> Result<(), String> {
|
||||
let mut raw = config.get_extensions_raw();
|
||||
|
||||
let yaml_key = serde_yaml::Value::String(config_key.clone());
|
||||
if let Some(entry) = raw.get_mut(&yaml_key) {
|
||||
if let serde_yaml::Value::Mapping(ref mut map) = entry {
|
||||
map.insert(
|
||||
serde_yaml::Value::String("enabled".to_string()),
|
||||
serde_yaml::Value::Bool(enabled),
|
||||
);
|
||||
}
|
||||
config.set_extensions_raw(raw)
|
||||
} else {
|
||||
Err(format!("Extension '{}' not found", config_key))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub mod agent_setup;
|
||||
pub mod agents;
|
||||
pub mod credentials;
|
||||
pub mod doctor;
|
||||
pub mod extensions;
|
||||
pub mod git;
|
||||
pub mod git_changes;
|
||||
pub mod model_setup;
|
||||
|
||||
@@ -78,6 +78,10 @@ pub fn run() {
|
||||
commands::projects::restore_project,
|
||||
commands::doctor::run_doctor,
|
||||
commands::doctor::run_doctor_fix,
|
||||
commands::extensions::list_extensions,
|
||||
commands::extensions::add_extension,
|
||||
commands::extensions::remove_extension,
|
||||
commands::extensions::toggle_extension,
|
||||
commands::git::get_git_state,
|
||||
commands::git_changes::get_changed_files,
|
||||
commands::git::git_switch_branch,
|
||||
|
||||
@@ -321,6 +321,26 @@ impl GooseConfig {
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn get_extensions_raw(&self) -> serde_yaml::Mapping {
|
||||
let config = self.read_config_map();
|
||||
let key = serde_yaml::Value::String("extensions".to_string());
|
||||
config
|
||||
.get(&key)
|
||||
.and_then(|v| v.as_mapping())
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set_extensions_raw(&self, extensions: serde_yaml::Mapping) -> Result<(), String> {
|
||||
let _guard = self.guard.lock().unwrap();
|
||||
let mut config = self.read_config_map();
|
||||
config.insert(
|
||||
serde_yaml::Value::String("extensions".to_string()),
|
||||
serde_yaml::Value::Mapping(extensions),
|
||||
);
|
||||
self.write_config_map(&config)
|
||||
}
|
||||
|
||||
pub fn delete_all_provider_fields(&self, provider_id: &str) -> Result<(), String> {
|
||||
let def = find_provider_def(provider_id)
|
||||
.ok_or_else(|| format!("Unknown provider '{provider_id}'"))?;
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { WorkingContext } from "../stores/chatSessionStore";
|
||||
import { WorkspaceWidget } from "./widgets/WorkspaceWidget";
|
||||
import { ChangesWidget } from "./widgets/ChangesWidget";
|
||||
import { ArtifactsWidget } from "./widgets/ArtifactsWidget";
|
||||
import { McpServersWidget } from "./widgets/McpServersWidget";
|
||||
import { ExtensionsWidget } from "./widgets/ExtensionsWidget";
|
||||
import { openPath } from "@tauri-apps/plugin-opener";
|
||||
|
||||
interface ContextPanelProps {
|
||||
@@ -218,7 +218,7 @@ export function ContextPanel({
|
||||
onOpenFile={handleOpenChangedFile}
|
||||
/>
|
||||
<ArtifactsWidget />
|
||||
<McpServersWidget />
|
||||
<ExtensionsWidget />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconPuzzle, IconSearch } from "@tabler/icons-react";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Widget } from "./Widget";
|
||||
import { listExtensions } from "@/features/extensions/api/extensions";
|
||||
import {
|
||||
getDisplayName,
|
||||
type ExtensionEntry,
|
||||
} from "@/features/extensions/types";
|
||||
|
||||
export function ExtensionsWidget() {
|
||||
const { t } = useTranslation("chat");
|
||||
const [extensions, setExtensions] = useState<ExtensionEntry[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const fetchEnabled = useCallback(() => {
|
||||
listExtensions()
|
||||
.then((all) => setExtensions(all.filter((e) => e.enabled)))
|
||||
.catch(() => setExtensions([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEnabled();
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === "visible") fetchEnabled();
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
window.addEventListener("focus", fetchEnabled);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
window.removeEventListener("focus", fetchEnabled);
|
||||
};
|
||||
}, [fetchEnabled]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchTerm) return extensions;
|
||||
const q = searchTerm.toLowerCase();
|
||||
return extensions.filter((ext) => {
|
||||
const name = getDisplayName(ext).toLowerCase();
|
||||
return (
|
||||
name.includes(q) || (ext.description ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [extensions, searchTerm]);
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title={t("contextPanel.widgets.extensions")}
|
||||
icon={<IconPuzzle className="size-3.5" />}
|
||||
flush
|
||||
>
|
||||
{extensions.length === 0 ? (
|
||||
<p className="px-3 py-2.5 text-xs text-foreground-subtle">
|
||||
{t("contextPanel.empty.noExtensions")}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="border-b border-border px-3 py-1.5">
|
||||
<div className="flex items-center gap-1.5 text-foreground-subtle">
|
||||
<IconSearch className="size-3" />
|
||||
<Input
|
||||
variant="ghost"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t("contextPanel.widgets.searchExtensions")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto px-3 py-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="py-1 text-xs text-foreground-subtle">
|
||||
{t("contextPanel.empty.noMatchingExtensions")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((ext) => (
|
||||
<div key={ext.config_key} className="flex items-center gap-2">
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-green-500" />
|
||||
<span className="truncate text-xs">
|
||||
{getDisplayName(ext)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconServer } from "@tabler/icons-react";
|
||||
import { Widget } from "./Widget";
|
||||
|
||||
export function McpServersWidget() {
|
||||
const { t } = useTranslation("chat");
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title={t("contextPanel.widgets.mcpServers")}
|
||||
icon={<IconServer className="size-3.5" />}
|
||||
>
|
||||
<p className="text-foreground-subtle">
|
||||
{t("contextPanel.empty.noServersConfigured")}
|
||||
</p>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { ExtensionConfig, ExtensionEntry } from "../types";
|
||||
|
||||
export function nameToKey(name: string): string {
|
||||
return name
|
||||
.replace(/\s/g, "")
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export async function listExtensions(): Promise<ExtensionEntry[]> {
|
||||
return invoke("list_extensions");
|
||||
}
|
||||
|
||||
export async function addExtension(
|
||||
name: string,
|
||||
extensionConfig: ExtensionConfig,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
return invoke("add_extension", {
|
||||
name,
|
||||
extensionConfig,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeExtension(configKey: string): Promise<void> {
|
||||
return invoke("remove_extension", { configKey });
|
||||
}
|
||||
|
||||
export async function toggleExtension(
|
||||
configKey: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
return invoke("toggle_extension", { configKey, enabled });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface StdioExtensionConfig {
|
||||
type: "stdio";
|
||||
name: string;
|
||||
description: string;
|
||||
cmd: string;
|
||||
args: string[];
|
||||
envs?: Record<string, string>;
|
||||
env_keys?: string[];
|
||||
timeout?: number;
|
||||
bundled?: boolean;
|
||||
available_tools?: string[];
|
||||
}
|
||||
|
||||
export interface BuiltinExtensionConfig {
|
||||
type: "builtin";
|
||||
name: string;
|
||||
description: string;
|
||||
display_name?: string;
|
||||
timeout?: number;
|
||||
bundled?: boolean;
|
||||
available_tools?: string[];
|
||||
}
|
||||
|
||||
export interface StreamableHttpExtensionConfig {
|
||||
type: "streamable_http";
|
||||
name: string;
|
||||
description: string;
|
||||
uri: string;
|
||||
envs?: Record<string, string>;
|
||||
env_keys?: string[];
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
bundled?: boolean;
|
||||
available_tools?: string[];
|
||||
}
|
||||
|
||||
export interface SseExtensionConfig {
|
||||
type: "sse";
|
||||
name: string;
|
||||
description: string;
|
||||
uri?: string;
|
||||
bundled?: boolean;
|
||||
}
|
||||
|
||||
export type ExtensionConfig =
|
||||
| StdioExtensionConfig
|
||||
| BuiltinExtensionConfig
|
||||
| StreamableHttpExtensionConfig
|
||||
| SseExtensionConfig;
|
||||
|
||||
export type ExtensionEntry = ExtensionConfig & {
|
||||
config_key: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function getDisplayName(ext: ExtensionEntry): string {
|
||||
if (ext.type === "builtin" && ext.display_name) {
|
||||
return ext.display_name;
|
||||
}
|
||||
return ext.name;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { getDisplayName, type ExtensionEntry } from "../types";
|
||||
|
||||
interface ExtensionItemProps {
|
||||
extension: ExtensionEntry;
|
||||
onToggle: (extension: ExtensionEntry) => Promise<void>;
|
||||
onConfigure?: (extension: ExtensionEntry) => void;
|
||||
}
|
||||
|
||||
function getSubtitle(ext: ExtensionEntry): string {
|
||||
if (ext.description) return ext.description;
|
||||
if (ext.type === "stdio") return ext.cmd;
|
||||
if (ext.type === "streamable_http") return ext.uri;
|
||||
return ext.type;
|
||||
}
|
||||
|
||||
const EDITABLE_TYPES = new Set(["stdio", "streamable_http"]);
|
||||
|
||||
function isEditable(ext: ExtensionEntry): boolean {
|
||||
return EDITABLE_TYPES.has(ext.type) && !ext.bundled;
|
||||
}
|
||||
|
||||
export function ExtensionItem({
|
||||
extension,
|
||||
onToggle,
|
||||
onConfigure,
|
||||
}: ExtensionItemProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
const [visualEnabled, setVisualEnabled] = useState(extension.enabled);
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (isToggling) return;
|
||||
setIsToggling(true);
|
||||
setVisualEnabled(!extension.enabled);
|
||||
try {
|
||||
await onToggle(extension);
|
||||
} catch {
|
||||
setVisualEnabled(extension.enabled);
|
||||
} finally {
|
||||
setIsToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const editable = isEditable(extension);
|
||||
const checked = isToggling ? visualEnabled : extension.enabled;
|
||||
const displayName = getDisplayName(extension);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t(`extensions.types.${extension.type}`, {
|
||||
defaultValue: extension.type,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{getSubtitle(extension)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{editable && onConfigure && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => onConfigure(extension)}
|
||||
aria-label={t("extensions.configure", {
|
||||
name: displayName,
|
||||
})}
|
||||
>
|
||||
<IconSettings className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isToggling}
|
||||
aria-label={t("extensions.toggle", {
|
||||
name: displayName,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import type { ExtensionConfig, ExtensionEntry } from "../types";
|
||||
|
||||
type ExtensionType = "stdio" | "streamable_http";
|
||||
|
||||
interface ExtensionModalProps {
|
||||
extension?: ExtensionEntry;
|
||||
onSubmit: (
|
||||
name: string,
|
||||
config: ExtensionConfig,
|
||||
enabled: boolean,
|
||||
) => Promise<void>;
|
||||
onDelete?: (configKey: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface EnvVar {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
let nextEnvId = 0;
|
||||
|
||||
function parseEnvVars(envs?: Record<string, string>): EnvVar[] {
|
||||
if (!envs || Object.keys(envs).length === 0)
|
||||
return [{ id: nextEnvId++, key: "", value: "" }];
|
||||
return Object.entries(envs).map(([key, value]) => ({
|
||||
id: nextEnvId++,
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildEnvVars(vars: EnvVar[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const v of vars) {
|
||||
if (v.key.trim()) {
|
||||
result[v.key.trim()] = v.value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ExtensionModal({
|
||||
extension,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: ExtensionModalProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const isEdit = !!extension;
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [name, setName] = useState(extension?.name ?? "");
|
||||
const [type, setType] = useState<ExtensionType>(
|
||||
extension?.type === "streamable_http" || extension?.type === "sse"
|
||||
? "streamable_http"
|
||||
: "stdio",
|
||||
);
|
||||
const [description, setDescription] = useState(extension?.description ?? "");
|
||||
const [cmd, setCmd] = useState(
|
||||
extension?.type === "stdio" ? extension.cmd : "",
|
||||
);
|
||||
const [args, setArgs] = useState(
|
||||
extension?.type === "stdio" ? extension.args.join("\n") : "",
|
||||
);
|
||||
const [uri, setUri] = useState(
|
||||
extension?.type === "streamable_http"
|
||||
? extension.uri
|
||||
: extension?.type === "sse"
|
||||
? (extension.uri ?? "")
|
||||
: "",
|
||||
);
|
||||
const [timeout, setTimeout] = useState(
|
||||
String(
|
||||
extension?.type === "stdio" || extension?.type === "streamable_http"
|
||||
? (extension.timeout ?? 300)
|
||||
: 300,
|
||||
),
|
||||
);
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>(() => {
|
||||
if (extension?.type === "stdio") return parseEnvVars(extension.envs);
|
||||
if (extension?.type === "streamable_http")
|
||||
return parseEnvVars(extension.envs);
|
||||
return [{ id: nextEnvId++, key: "", value: "" }];
|
||||
});
|
||||
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
(type === "stdio" ? cmd.trim().length > 0 : uri.trim().length > 0);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || isSaving) return;
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim();
|
||||
const envs = buildEnvVars(envVars);
|
||||
const timeoutNum = Number.parseInt(timeout, 10) || 300;
|
||||
|
||||
let config: ExtensionConfig;
|
||||
|
||||
if (type === "stdio") {
|
||||
config = {
|
||||
...(extension?.type === "stdio" ? extension : {}),
|
||||
type: "stdio",
|
||||
name: trimmedName,
|
||||
description,
|
||||
cmd: cmd.trim(),
|
||||
args: args
|
||||
.split("\n")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean),
|
||||
envs,
|
||||
timeout: timeoutNum,
|
||||
};
|
||||
} else {
|
||||
if (!uri.trim()) return;
|
||||
config = {
|
||||
...(extension?.type === "streamable_http" ? extension : {}),
|
||||
type: "streamable_http",
|
||||
name: trimmedName,
|
||||
description,
|
||||
uri: uri.trim(),
|
||||
envs,
|
||||
timeout: timeoutNum,
|
||||
};
|
||||
}
|
||||
|
||||
await onSubmit(trimmedName, config, extension?.enabled ?? true);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateEnvVar = (index: number, field: "key" | "value", val: string) => {
|
||||
setEnvVars((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], [field]: val };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addEnvVar = () => {
|
||||
setEnvVars((prev) => [...prev, { id: nextEnvId++, key: "", value: "" }]);
|
||||
};
|
||||
|
||||
const removeEnvVar = (id: number) => {
|
||||
setEnvVars((prev) => {
|
||||
if (prev.length <= 1) return [{ id: nextEnvId++, key: "", value: "" }];
|
||||
return prev.filter((v) => v.id !== id);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit
|
||||
? t("extensions.editExtension")
|
||||
: t("extensions.addExtension")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-name">{t("extensions.fields.name")}</Label>
|
||||
<Input
|
||||
id="ext-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("extensions.fields.namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-type">{t("extensions.fields.type")}</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(v) => setType(v as ExtensionType)}
|
||||
>
|
||||
<SelectTrigger id="ext-type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">
|
||||
{t("extensions.types.stdio")}
|
||||
</SelectItem>
|
||||
<SelectItem value="streamable_http">
|
||||
{t("extensions.types.streamable_http")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-desc">
|
||||
{t("extensions.fields.description")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ext-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("extensions.fields.descriptionPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{type === "stdio" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-cmd">
|
||||
{t("extensions.fields.command")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ext-cmd"
|
||||
value={cmd}
|
||||
onChange={(e) => setCmd(e.target.value)}
|
||||
placeholder={t("extensions.fields.commandPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-args">
|
||||
{t("extensions.fields.arguments")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="ext-args"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
placeholder={t("extensions.fields.argumentsPlaceholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "streamable_http" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-uri">{t("extensions.fields.url")}</Label>
|
||||
<Input
|
||||
id="ext-uri"
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
placeholder={t("extensions.fields.urlPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-timeout">
|
||||
{t("extensions.fields.timeout")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ext-timeout"
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeout(e.target.value)}
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("extensions.fields.envVars")}</Label>
|
||||
<div className="space-y-2">
|
||||
{envVars.map((env, i) => (
|
||||
<div key={env.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={env.key}
|
||||
onChange={(e) => updateEnvVar(i, "key", e.target.value)}
|
||||
placeholder={t("extensions.fields.envKeyPlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={env.value}
|
||||
onChange={(e) => updateEnvVar(i, "value", e.target.value)}
|
||||
placeholder={t("extensions.fields.envValuePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeEnvVar(env.id)}
|
||||
className="shrink-0 hover:text-destructive"
|
||||
aria-label={t("extensions.fields.removeEnvVar")}
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addEnvVar}
|
||||
>
|
||||
<IconPlus className="size-3.5" />
|
||||
{t("extensions.fields.addEnvVar")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{isEdit && onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onDelete(extension.config_key);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="mr-auto text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<IconTrash className="size-4" />
|
||||
{t("extensions.deleteExtension")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t("extensions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || isSaving}
|
||||
>
|
||||
{t("extensions.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import {
|
||||
listExtensions,
|
||||
addExtension,
|
||||
removeExtension,
|
||||
toggleExtension,
|
||||
nameToKey,
|
||||
} from "../api/extensions";
|
||||
import {
|
||||
getDisplayName,
|
||||
type ExtensionConfig,
|
||||
type ExtensionEntry,
|
||||
} from "../types";
|
||||
import { ExtensionItem } from "./ExtensionItem";
|
||||
import { ExtensionModal } from "./ExtensionModal";
|
||||
|
||||
export function ExtensionsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [extensions, setExtensions] = useState<ExtensionEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [modalMode, setModalMode] = useState<"add" | "edit" | null>(null);
|
||||
const [editingExtension, setEditingExtension] =
|
||||
useState<ExtensionEntry | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const fetchExtensions = useCallback(async () => {
|
||||
try {
|
||||
const result = await listExtensions();
|
||||
setExtensions(result);
|
||||
} catch {
|
||||
setExtensions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchExtensions();
|
||||
}, [fetchExtensions]);
|
||||
|
||||
const matchesSearch = useCallback(
|
||||
(ext: ExtensionEntry) => {
|
||||
if (!searchTerm) return true;
|
||||
const q = searchTerm.toLowerCase();
|
||||
return (
|
||||
getDisplayName(ext).toLowerCase().includes(q) ||
|
||||
ext.name.toLowerCase().includes(q) ||
|
||||
(ext.description ?? "").toLowerCase().includes(q) ||
|
||||
ext.type.toLowerCase().includes(q)
|
||||
);
|
||||
},
|
||||
[searchTerm],
|
||||
);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...extensions].sort((a, b) => {
|
||||
if (a.type === "builtin" && b.type !== "builtin") return -1;
|
||||
if (a.type !== "builtin" && b.type === "builtin") return 1;
|
||||
const aBundled = a.bundled === true;
|
||||
const bBundled = b.bundled === true;
|
||||
if (aBundled && !bBundled) return -1;
|
||||
if (!aBundled && bBundled) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [extensions]);
|
||||
|
||||
const enabled = useMemo(
|
||||
() => sorted.filter((e) => e.enabled && matchesSearch(e)),
|
||||
[sorted, matchesSearch],
|
||||
);
|
||||
const available = useMemo(
|
||||
() => sorted.filter((e) => !e.enabled && matchesSearch(e)),
|
||||
[sorted, matchesSearch],
|
||||
);
|
||||
|
||||
const handleToggle = async (ext: ExtensionEntry) => {
|
||||
try {
|
||||
await toggleExtension(ext.config_key, !ext.enabled);
|
||||
await fetchExtensions();
|
||||
} catch {
|
||||
toast.error(t("extensions.errors.toggleFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigure = (ext: ExtensionEntry) => {
|
||||
setEditingExtension(ext);
|
||||
setModalMode("edit");
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
name: string,
|
||||
config: ExtensionConfig,
|
||||
extensionEnabled: boolean,
|
||||
) => {
|
||||
try {
|
||||
const newKey = nameToKey(name);
|
||||
const isEdit = !!editingExtension;
|
||||
const isAdd = !editingExtension;
|
||||
const keyChanged = isEdit && editingExtension.config_key !== newKey;
|
||||
|
||||
if (
|
||||
(isAdd || keyChanged) &&
|
||||
extensions.some((e) => e.config_key === newKey)
|
||||
) {
|
||||
toast.error(t("extensions.errors.nameConflict", { name }));
|
||||
return;
|
||||
}
|
||||
|
||||
await addExtension(name, config, extensionEnabled);
|
||||
if (keyChanged) {
|
||||
await removeExtension(editingExtension.config_key);
|
||||
}
|
||||
setModalMode(null);
|
||||
setEditingExtension(null);
|
||||
await fetchExtensions();
|
||||
} catch {
|
||||
toast.error(t("extensions.errors.saveFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (configKey: string) => {
|
||||
try {
|
||||
await removeExtension(configKey);
|
||||
setModalMode(null);
|
||||
setEditingExtension(null);
|
||||
await fetchExtensions();
|
||||
} catch {
|
||||
toast.error(t("extensions.errors.deleteFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalMode(null);
|
||||
setEditingExtension(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-display text-lg font-semibold tracking-tight">
|
||||
{t("extensions.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("extensions.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<IconSearch className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t("extensions.search")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="divide-y divide-border">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-14 animate-pulse bg-muted/30" />
|
||||
))}
|
||||
</div>
|
||||
) : extensions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("extensions.empty")}</p>
|
||||
) : enabled.length === 0 && available.length === 0 && searchTerm ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("extensions.noResults")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{enabled.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("extensions.enabledCount", { count: enabled.length })}
|
||||
</h4>
|
||||
<div className="divide-y divide-border">
|
||||
{enabled.map((ext) => (
|
||||
<ExtensionItem
|
||||
key={ext.config_key}
|
||||
extension={ext}
|
||||
onToggle={handleToggle}
|
||||
onConfigure={handleConfigure}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{available.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("extensions.availableCount", { count: available.length })}
|
||||
</h4>
|
||||
<div className="divide-y divide-border">
|
||||
{available.map((ext) => (
|
||||
<ExtensionItem
|
||||
key={ext.config_key}
|
||||
extension={ext}
|
||||
onToggle={handleToggle}
|
||||
onConfigure={handleConfigure}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingExtension(null);
|
||||
setModalMode("add");
|
||||
}}
|
||||
>
|
||||
<IconPlus className="size-4" />
|
||||
{t("extensions.addExtension")}
|
||||
</Button>
|
||||
|
||||
{modalMode === "add" && (
|
||||
<ExtensionModal onSubmit={handleSubmit} onClose={handleModalClose} />
|
||||
)}
|
||||
|
||||
{modalMode === "edit" && editingExtension && (
|
||||
<ExtensionModal
|
||||
extension={editingExtension}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,11 @@ import {
|
||||
Stethoscope,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { IconPlug } from "@tabler/icons-react";
|
||||
import { IconPlug, IconPuzzle } from "@tabler/icons-react";
|
||||
import { AppearanceSettings } from "./AppearanceSettings";
|
||||
import { DoctorSettings } from "./DoctorSettings";
|
||||
import { ProvidersSettings } from "./ProvidersSettings";
|
||||
import { ExtensionsSettings } from "@/features/extensions/ui/ExtensionsSettings";
|
||||
import {
|
||||
listArchivedProjects,
|
||||
restoreProject,
|
||||
@@ -48,6 +49,7 @@ import type { Session } from "@/shared/types/chat";
|
||||
const NAV_ITEMS = [
|
||||
{ id: "appearance", labelKey: "nav.appearance", icon: Palette },
|
||||
{ id: "providers", labelKey: "nav.providers", icon: IconPlug },
|
||||
{ id: "extensions", labelKey: "nav.extensions", icon: IconPuzzle },
|
||||
{ id: "general", labelKey: "nav.general", icon: Settings2 },
|
||||
{ id: "projects", labelKey: "nav.projects", icon: FolderKanban },
|
||||
{ id: "chats", labelKey: "nav.chats", icon: MessageSquare },
|
||||
@@ -238,6 +240,7 @@ export function SettingsModal({
|
||||
>
|
||||
{activeSection === "appearance" && <AppearanceSettings />}
|
||||
{activeSection === "providers" && <ProvidersSettings />}
|
||||
{activeSection === "extensions" && <ExtensionsSettings />}
|
||||
{activeSection === "doctor" && <DoctorSettings />}
|
||||
{activeSection === "general" && (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"folderNotSet": "Folder not set",
|
||||
"noChanges": "No uncommitted changes",
|
||||
"noProjectAssigned": "No project assigned.",
|
||||
"noServersConfigured": "No servers configured"
|
||||
"noExtensions": "No extensions enabled",
|
||||
"noMatchingExtensions": "No matching extensions"
|
||||
},
|
||||
"errors": {
|
||||
"gitRead": "Unable to read git status."
|
||||
@@ -89,7 +90,8 @@
|
||||
"artifacts": "Artifacts",
|
||||
"changes": "Changes",
|
||||
"changesOnBranch": "on",
|
||||
"mcpServers": "MCP Servers",
|
||||
"extensions": "Extensions",
|
||||
"searchExtensions": "Search extensions...",
|
||||
"workspace": "Workspace"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -55,6 +55,54 @@
|
||||
"description": "Are you sure you want to permanently delete \"{{name}}\"? This cannot be undone.",
|
||||
"title": "Delete project permanently?"
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensions",
|
||||
"description": "Manage MCP extensions that add tools and capabilities to Goose.",
|
||||
"search": "Search extensions...",
|
||||
"enabled": "Enabled",
|
||||
"enabledCount": "Enabled ({{count}})",
|
||||
"available": "Available",
|
||||
"availableCount": "Available ({{count}})",
|
||||
"empty": "No extensions configured.",
|
||||
"noResults": "No extensions match your search.",
|
||||
"addExtension": "Add Extension",
|
||||
"editExtension": "Edit Extension",
|
||||
"deleteExtension": "Delete Extension",
|
||||
"toggle": "Toggle {{name}}",
|
||||
"configure": "Configure {{name}}",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"namePlaceholder": "my-extension",
|
||||
"type": "Type",
|
||||
"command": "Command",
|
||||
"commandPlaceholder": "npx",
|
||||
"arguments": "Arguments",
|
||||
"argumentsPlaceholder": "One argument per line",
|
||||
"url": "URL",
|
||||
"urlPlaceholder": "http://localhost:3000/mcp",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What does this extension do?",
|
||||
"timeout": "Timeout (seconds)",
|
||||
"envVars": "Environment Variables",
|
||||
"envKeyPlaceholder": "KEY",
|
||||
"envValuePlaceholder": "value",
|
||||
"addEnvVar": "Add variable",
|
||||
"removeEnvVar": "Remove variable"
|
||||
},
|
||||
"types": {
|
||||
"stdio": "Stdio",
|
||||
"streamable_http": "HTTP",
|
||||
"builtin": "Built-in"
|
||||
},
|
||||
"errors": {
|
||||
"toggleFailed": "Failed to toggle extension. Please try again.",
|
||||
"saveFailed": "Failed to save extension. Please try again.",
|
||||
"deleteFailed": "Failed to delete extension. Please try again.",
|
||||
"nameConflict": "An extension named \"{{name}}\" already exists."
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"agents": "Agents",
|
||||
"copied": "Copied",
|
||||
@@ -85,6 +133,7 @@
|
||||
"doctor": "Doctor",
|
||||
"general": "General",
|
||||
"projects": "Projects",
|
||||
"extensions": "Extensions",
|
||||
"providers": "Providers"
|
||||
},
|
||||
"projects": {
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"folderNotSet": "Carpeta no configurada",
|
||||
"noChanges": "No hay cambios sin confirmar",
|
||||
"noProjectAssigned": "No hay proyecto asignado.",
|
||||
"noServersConfigured": "No hay servidores configurados"
|
||||
"noExtensions": "No hay extensiones habilitadas",
|
||||
"noMatchingExtensions": "No hay extensiones que coincidan"
|
||||
},
|
||||
"errors": {
|
||||
"gitRead": "No se pudo leer el estado de git."
|
||||
@@ -89,7 +90,8 @@
|
||||
"artifacts": "Artefactos",
|
||||
"changes": "Cambios",
|
||||
"changesOnBranch": "en",
|
||||
"mcpServers": "Servidores MCP",
|
||||
"extensions": "Extensiones",
|
||||
"searchExtensions": "Buscar extensiones...",
|
||||
"workspace": "Espacio de trabajo"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -55,6 +55,54 @@
|
||||
"description": "¿Seguro que quieres eliminar permanentemente \"{{name}}\"? Esta acción no se puede deshacer.",
|
||||
"title": "¿Eliminar el proyecto de forma permanente?"
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensiones",
|
||||
"description": "Administra las extensiones MCP que agregan herramientas y capacidades a Goose.",
|
||||
"search": "Buscar extensiones...",
|
||||
"enabled": "Habilitadas",
|
||||
"enabledCount": "Habilitadas ({{count}})",
|
||||
"available": "Disponibles",
|
||||
"availableCount": "Disponibles ({{count}})",
|
||||
"empty": "No hay extensiones configuradas.",
|
||||
"noResults": "Ninguna extensión coincide con tu búsqueda.",
|
||||
"addExtension": "Agregar extensión",
|
||||
"editExtension": "Editar extensión",
|
||||
"deleteExtension": "Eliminar extensión",
|
||||
"toggle": "Activar/desactivar {{name}}",
|
||||
"configure": "Configurar {{name}}",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"fields": {
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "mi-extension",
|
||||
"type": "Tipo",
|
||||
"command": "Comando",
|
||||
"commandPlaceholder": "npx",
|
||||
"arguments": "Argumentos",
|
||||
"argumentsPlaceholder": "Un argumento por línea",
|
||||
"url": "URL",
|
||||
"urlPlaceholder": "http://localhost:3000/mcp",
|
||||
"description": "Descripción",
|
||||
"descriptionPlaceholder": "¿Qué hace esta extensión?",
|
||||
"timeout": "Tiempo de espera (segundos)",
|
||||
"envVars": "Variables de entorno",
|
||||
"envKeyPlaceholder": "CLAVE",
|
||||
"envValuePlaceholder": "valor",
|
||||
"addEnvVar": "Agregar variable",
|
||||
"removeEnvVar": "Eliminar variable"
|
||||
},
|
||||
"types": {
|
||||
"stdio": "Stdio",
|
||||
"streamable_http": "HTTP",
|
||||
"builtin": "Integrada"
|
||||
},
|
||||
"errors": {
|
||||
"toggleFailed": "Error al cambiar la extensión. Inténtalo de nuevo.",
|
||||
"saveFailed": "Error al guardar la extensión. Inténtalo de nuevo.",
|
||||
"deleteFailed": "Error al eliminar la extensión. Inténtalo de nuevo.",
|
||||
"nameConflict": "Ya existe una extensión llamada \"{{name}}\"."
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"agents": "Agentes",
|
||||
"copied": "Copiado",
|
||||
@@ -85,6 +133,7 @@
|
||||
"doctor": "Diagnóstico",
|
||||
"general": "General",
|
||||
"projects": "Proyectos",
|
||||
"extensions": "Extensiones",
|
||||
"providers": "Proveedores"
|
||||
},
|
||||
"projects": {
|
||||
|
||||
@@ -2,17 +2,26 @@ import type * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
const variantStyles = {
|
||||
default: [
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input hover:border-border-input-hover focus-visible:border-ring focus-visible:ring-0 focus-visible:ring-offset-0 flex h-9 w-full min-w-0 rounded-input border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
],
|
||||
ghost: [
|
||||
"w-full border-none bg-transparent text-sm text-foreground shadow-none outline-none placeholder:text-muted-foreground focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
],
|
||||
} as const;
|
||||
|
||||
interface InputProps extends React.ComponentProps<"input"> {
|
||||
variant?: keyof typeof variantStyles;
|
||||
}
|
||||
|
||||
function Input({ className, type, variant = "default", ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input hover:border-border-input-hover focus-visible:border-ring focus-visible:ring-0 focus-visible:ring-offset-0 flex h-9 w-full min-w-0 rounded-input border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
className={cn(...variantStyles[variant], className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user