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 agents;
|
||||||
pub mod credentials;
|
pub mod credentials;
|
||||||
pub mod doctor;
|
pub mod doctor;
|
||||||
|
pub mod extensions;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod git_changes;
|
pub mod git_changes;
|
||||||
pub mod model_setup;
|
pub mod model_setup;
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ pub fn run() {
|
|||||||
commands::projects::restore_project,
|
commands::projects::restore_project,
|
||||||
commands::doctor::run_doctor,
|
commands::doctor::run_doctor,
|
||||||
commands::doctor::run_doctor_fix,
|
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::get_git_state,
|
||||||
commands::git_changes::get_changed_files,
|
commands::git_changes::get_changed_files,
|
||||||
commands::git::git_switch_branch,
|
commands::git::git_switch_branch,
|
||||||
|
|||||||
@@ -321,6 +321,26 @@ impl GooseConfig {
|
|||||||
.collect())
|
.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> {
|
pub fn delete_all_provider_fields(&self, provider_id: &str) -> Result<(), String> {
|
||||||
let def = find_provider_def(provider_id)
|
let def = find_provider_def(provider_id)
|
||||||
.ok_or_else(|| format!("Unknown provider '{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 { WorkspaceWidget } from "./widgets/WorkspaceWidget";
|
||||||
import { ChangesWidget } from "./widgets/ChangesWidget";
|
import { ChangesWidget } from "./widgets/ChangesWidget";
|
||||||
import { ArtifactsWidget } from "./widgets/ArtifactsWidget";
|
import { ArtifactsWidget } from "./widgets/ArtifactsWidget";
|
||||||
import { McpServersWidget } from "./widgets/McpServersWidget";
|
import { ExtensionsWidget } from "./widgets/ExtensionsWidget";
|
||||||
import { openPath } from "@tauri-apps/plugin-opener";
|
import { openPath } from "@tauri-apps/plugin-opener";
|
||||||
|
|
||||||
interface ContextPanelProps {
|
interface ContextPanelProps {
|
||||||
@@ -218,7 +218,7 @@ export function ContextPanel({
|
|||||||
onOpenFile={handleOpenChangedFile}
|
onOpenFile={handleOpenChangedFile}
|
||||||
/>
|
/>
|
||||||
<ArtifactsWidget />
|
<ArtifactsWidget />
|
||||||
<McpServersWidget />
|
<ExtensionsWidget />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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,
|
Stethoscope,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconPlug } from "@tabler/icons-react";
|
import { IconPlug, IconPuzzle } from "@tabler/icons-react";
|
||||||
import { AppearanceSettings } from "./AppearanceSettings";
|
import { AppearanceSettings } from "./AppearanceSettings";
|
||||||
import { DoctorSettings } from "./DoctorSettings";
|
import { DoctorSettings } from "./DoctorSettings";
|
||||||
import { ProvidersSettings } from "./ProvidersSettings";
|
import { ProvidersSettings } from "./ProvidersSettings";
|
||||||
|
import { ExtensionsSettings } from "@/features/extensions/ui/ExtensionsSettings";
|
||||||
import {
|
import {
|
||||||
listArchivedProjects,
|
listArchivedProjects,
|
||||||
restoreProject,
|
restoreProject,
|
||||||
@@ -48,6 +49,7 @@ import type { Session } from "@/shared/types/chat";
|
|||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ id: "appearance", labelKey: "nav.appearance", icon: Palette },
|
{ id: "appearance", labelKey: "nav.appearance", icon: Palette },
|
||||||
{ id: "providers", labelKey: "nav.providers", icon: IconPlug },
|
{ id: "providers", labelKey: "nav.providers", icon: IconPlug },
|
||||||
|
{ id: "extensions", labelKey: "nav.extensions", icon: IconPuzzle },
|
||||||
{ id: "general", labelKey: "nav.general", icon: Settings2 },
|
{ id: "general", labelKey: "nav.general", icon: Settings2 },
|
||||||
{ id: "projects", labelKey: "nav.projects", icon: FolderKanban },
|
{ id: "projects", labelKey: "nav.projects", icon: FolderKanban },
|
||||||
{ id: "chats", labelKey: "nav.chats", icon: MessageSquare },
|
{ id: "chats", labelKey: "nav.chats", icon: MessageSquare },
|
||||||
@@ -238,6 +240,7 @@ export function SettingsModal({
|
|||||||
>
|
>
|
||||||
{activeSection === "appearance" && <AppearanceSettings />}
|
{activeSection === "appearance" && <AppearanceSettings />}
|
||||||
{activeSection === "providers" && <ProvidersSettings />}
|
{activeSection === "providers" && <ProvidersSettings />}
|
||||||
|
{activeSection === "extensions" && <ExtensionsSettings />}
|
||||||
{activeSection === "doctor" && <DoctorSettings />}
|
{activeSection === "doctor" && <DoctorSettings />}
|
||||||
{activeSection === "general" && (
|
{activeSection === "general" && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
"folderNotSet": "Folder not set",
|
"folderNotSet": "Folder not set",
|
||||||
"noChanges": "No uncommitted changes",
|
"noChanges": "No uncommitted changes",
|
||||||
"noProjectAssigned": "No project assigned.",
|
"noProjectAssigned": "No project assigned.",
|
||||||
"noServersConfigured": "No servers configured"
|
"noExtensions": "No extensions enabled",
|
||||||
|
"noMatchingExtensions": "No matching extensions"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"gitRead": "Unable to read git status."
|
"gitRead": "Unable to read git status."
|
||||||
@@ -89,7 +90,8 @@
|
|||||||
"artifacts": "Artifacts",
|
"artifacts": "Artifacts",
|
||||||
"changes": "Changes",
|
"changes": "Changes",
|
||||||
"changesOnBranch": "on",
|
"changesOnBranch": "on",
|
||||||
"mcpServers": "MCP Servers",
|
"extensions": "Extensions",
|
||||||
|
"searchExtensions": "Search extensions...",
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,6 +55,54 @@
|
|||||||
"description": "Are you sure you want to permanently delete \"{{name}}\"? This cannot be undone.",
|
"description": "Are you sure you want to permanently delete \"{{name}}\"? This cannot be undone.",
|
||||||
"title": "Delete project permanently?"
|
"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": {
|
"doctor": {
|
||||||
"agents": "Agents",
|
"agents": "Agents",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
@@ -85,6 +133,7 @@
|
|||||||
"doctor": "Doctor",
|
"doctor": "Doctor",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
|
"extensions": "Extensions",
|
||||||
"providers": "Providers"
|
"providers": "Providers"
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
"folderNotSet": "Carpeta no configurada",
|
"folderNotSet": "Carpeta no configurada",
|
||||||
"noChanges": "No hay cambios sin confirmar",
|
"noChanges": "No hay cambios sin confirmar",
|
||||||
"noProjectAssigned": "No hay proyecto asignado.",
|
"noProjectAssigned": "No hay proyecto asignado.",
|
||||||
"noServersConfigured": "No hay servidores configurados"
|
"noExtensions": "No hay extensiones habilitadas",
|
||||||
|
"noMatchingExtensions": "No hay extensiones que coincidan"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"gitRead": "No se pudo leer el estado de git."
|
"gitRead": "No se pudo leer el estado de git."
|
||||||
@@ -89,7 +90,8 @@
|
|||||||
"artifacts": "Artefactos",
|
"artifacts": "Artefactos",
|
||||||
"changes": "Cambios",
|
"changes": "Cambios",
|
||||||
"changesOnBranch": "en",
|
"changesOnBranch": "en",
|
||||||
"mcpServers": "Servidores MCP",
|
"extensions": "Extensiones",
|
||||||
|
"searchExtensions": "Buscar extensiones...",
|
||||||
"workspace": "Espacio de trabajo"
|
"workspace": "Espacio de trabajo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,6 +55,54 @@
|
|||||||
"description": "¿Seguro que quieres eliminar permanentemente \"{{name}}\"? Esta acción no se puede deshacer.",
|
"description": "¿Seguro que quieres eliminar permanentemente \"{{name}}\"? Esta acción no se puede deshacer.",
|
||||||
"title": "¿Eliminar el proyecto de forma permanente?"
|
"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": {
|
"doctor": {
|
||||||
"agents": "Agentes",
|
"agents": "Agentes",
|
||||||
"copied": "Copiado",
|
"copied": "Copiado",
|
||||||
@@ -85,6 +133,7 @@
|
|||||||
"doctor": "Diagnóstico",
|
"doctor": "Diagnóstico",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"projects": "Proyectos",
|
"projects": "Proyectos",
|
||||||
|
"extensions": "Extensiones",
|
||||||
"providers": "Proveedores"
|
"providers": "Proveedores"
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
@@ -2,17 +2,26 @@ import type * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/shared/lib/cn";
|
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 (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(...variantStyles[variant], className)}
|
||||||
"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,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user