feat: config manager using secret store

This commit is contained in:
sam hoang
2025-01-05 00:52:00 +07:00
committed by Matt Rubens
parent c30e9c6ed3
commit 352f34d8ce
10 changed files with 1026 additions and 96 deletions

View File

@@ -0,0 +1,165 @@
import { VSCodeButton, VSCodeDivider, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { memo, useState } from "react"
import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
interface ApiConfigManagerProps {
currentApiConfigName?: string
listApiConfigMeta?: ApiConfigMeta[]
onSelectConfig: (configName: string) => void
onDeleteConfig: (configName: string) => void
onRenameConfig: (oldName: string, newName: string) => void
onUpsertConfig: (configName: string) => void
// setDraftNewConfig: (mode: boolean) => void
}
const ApiConfigManager = ({
currentApiConfigName,
listApiConfigMeta,
onSelectConfig,
onDeleteConfig,
onRenameConfig,
onUpsertConfig,
// setDraftNewConfig,
}: ApiConfigManagerProps) => {
const [isNewMode, setIsNewMode] = useState(false);
const [isRenameMode, setIsRenameMode] = useState(false);
const [newConfigName, setNewConfigName] = useState("");
const [renamedConfigName, setRenamedConfigName] = useState("");
const handleNewConfig = () => {
setIsNewMode(true);
setNewConfigName("");
// setDraftNewConfig(true)
};
const handleSaveNewConfig = () => {
if (newConfigName.trim()) {
onUpsertConfig(newConfigName.trim());
setIsNewMode(false);
setNewConfigName("");
// setDraftNewConfig(false)
}
};
const handleCancelNewConfig = () => {
setIsNewMode(false);
setNewConfigName("");
// setDraftNewConfig(false)
};
const handleStartRename = () => {
setIsRenameMode(true);
setRenamedConfigName(currentApiConfigName || "");
};
const handleSaveRename = () => {
if (renamedConfigName.trim() && currentApiConfigName) {
onRenameConfig(currentApiConfigName, renamedConfigName.trim());
setIsRenameMode(false);
setRenamedConfigName("");
}
};
const handleCancelRename = () => {
setIsRenameMode(false);
setRenamedConfigName("");
};
return (
<div>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>
API Configuration
</label>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
{isNewMode ? (
<>
<VSCodeTextField
value={newConfigName}
onInput={(e: any) => setNewConfigName(e.target.value)}
placeholder="Enter configuration name"
style={{ flexGrow: 1 }}
/>
<VSCodeButton
appearance="secondary"
disabled={!newConfigName.trim()}
onClick={handleSaveNewConfig}
>
<span className="codicon codicon-check" /> Save
</VSCodeButton>
<VSCodeButton
appearance="secondary"
onClick={handleCancelNewConfig}
>
<span className="codicon codicon-close" /> Cancel
</VSCodeButton>
</>
) : isRenameMode ? (
<>
<VSCodeTextField
value={renamedConfigName}
onInput={(e: any) => setRenamedConfigName(e.target.value)}
placeholder="Enter new name"
style={{ flexGrow: 1 }}
/>
<VSCodeButton
appearance="secondary"
disabled={!renamedConfigName.trim()}
onClick={handleSaveRename}
>
<span className="codicon codicon-check" /> Save
</VSCodeButton>
<VSCodeButton
appearance="secondary"
onClick={handleCancelRename}
>
<span className="codicon codicon-close" /> Cancel
</VSCodeButton>
</>
) : (
<>
<select
value={currentApiConfigName}
onChange={(e) => onSelectConfig(e.target.value)}
style={{
flexGrow: 1,
padding: "4px 8px",
backgroundColor: "var(--vscode-input-background)",
color: "var(--vscode-input-foreground)",
border: "1px solid var(--vscode-input-border)",
borderRadius: "2px",
height: "28px"
}}>
{listApiConfigMeta?.map((config) => (
<option key={config.name} value={config.name}>{config.name} {config.apiProvider ? `(${config.apiProvider})` : ""}
</option>
))}
</select>
<VSCodeButton
appearance="secondary"
onClick={handleNewConfig}
>
<span className="codicon codicon-add" /> New
</VSCodeButton>
<VSCodeButton
appearance="secondary"
disabled={!currentApiConfigName}
onClick={handleStartRename}
>
<span className="codicon codicon-edit" /> Rename
</VSCodeButton>
<VSCodeButton
appearance="secondary"
disabled={!currentApiConfigName}
onClick={() => onDeleteConfig(currentApiConfigName!)}
>
<span className="codicon codicon-trash" /> Delete
</VSCodeButton>
</>
)}
</div>
<VSCodeDivider style={{ margin: "15px 0" }} />
</div>
)
}
export default memo(ApiConfigManager)

View File

@@ -5,6 +5,7 @@ import { validateApiConfiguration, validateModelId } from "../../utils/validate"
import { vscode } from "../../utils/vscode"
import ApiOptions from "./ApiOptions"
import McpEnabledToggle from "../mcp/McpEnabledToggle"
import ApiConfigManager from "./ApiConfigManager"
const IS_DEV = false // FIXME: use flags when packaging
@@ -55,10 +56,15 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setAlwaysApproveResubmit,
requestDelaySeconds,
setRequestDelaySeconds,
currentApiConfigName,
listApiConfigMeta,
} = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
const [commandInput, setCommandInput] = useState("")
// const [draftNewMode, setDraftNewMode] = useState(false)
const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels)
@@ -89,6 +95,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentApiConfigName,
apiConfiguration
})
onDone()
}
}
@@ -150,6 +163,42 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</div>
<div
style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}>
<div style={{ marginBottom: 5 }}>
<ApiConfigManager
currentApiConfigName={currentApiConfigName}
listApiConfigMeta={listApiConfigMeta}
onSelectConfig={(configName: string) => {
vscode.postMessage({
type: "loadApiConfiguration",
text: configName
})
}}
onDeleteConfig={(configName: string) => {
vscode.postMessage({
type: "deleteApiConfiguration",
text: configName
})
}}
onRenameConfig={(oldName: string, newName: string) => {
vscode.postMessage({
type: "renameApiConfiguration",
values: {oldName, newName},
apiConfiguration
})
}}
onUpsertConfig={(configName: string) => {
vscode.postMessage({
type: "upsertApiConfiguration",
text: configName,
apiConfiguration
})
}}
// setDraftNewConfig={(mode: boolean) => {
// setDraftNewMode(mode)
// }}
/>
</div>
<div style={{ marginBottom: 5 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Provider Settings</h3>
<ApiOptions

View File

@@ -11,6 +11,16 @@ jest.mock('../../../utils/vscode', () => ({
},
}))
// Mock ApiConfigManager component
jest.mock('../ApiConfigManager', () => ({
__esModule: true,
default: ({ currentApiConfigName, listApiConfigMeta, onSelectConfig, onDeleteConfig, onRenameConfig, onUpsertConfig }: any) => (
<div data-testid="api-config-management">
<span>Current config: {currentApiConfigName}</span>
</div>
)
}))
// Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: ({ children, onClick, appearance }: any) => (
@@ -185,6 +195,18 @@ describe('SettingsView - Sound Settings', () => {
})
})
describe('SettingsView - API Configuration', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('renders ApiConfigManagement with correct props', () => {
renderSettingsView()
expect(screen.getByTestId('api-config-management')).toBeInTheDocument()
})
})
describe('SettingsView - Allowed Commands', () => {
beforeEach(() => {
jest.clearAllMocks()

View File

@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
import { useEvent } from "react-use"
import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage"
import { ApiConfigMeta, ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage"
import {
ApiConfiguration,
ModelInfo,
@@ -13,6 +13,9 @@ import { vscode } from "../utils/vscode"
import { convertTextMateToHljs } from "../utils/textMateToHljs"
import { findLastIndex } from "../../../src/shared/array"
import { McpServer } from "../../../src/shared/mcp"
import {
checkExistKey
} from "../../../src/shared/checkExistApiConfig"
export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
@@ -50,6 +53,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysApproveResubmit: (value: boolean) => void
requestDelaySeconds: number
setRequestDelaySeconds: (value: number) => void
setCurrentApiConfigName: (value: string) => void
setListApiConfigMeta: (value: ApiConfigMeta[]) => void
}
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -72,7 +77,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
terminalOutputLineLimit: 500,
mcpEnabled: true,
alwaysApproveResubmit: false,
requestDelaySeconds: 5
requestDelaySeconds: 5,
currentApiConfigName: 'default',
listApiConfigMeta: [],
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
@@ -88,27 +95,16 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [openAiModels, setOpenAiModels] = useState<string[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const setListApiConfigMeta = useCallback((value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), [setState])
const handleMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data
switch (message.type) {
case "state": {
setState(message.state!)
const config = message.state?.apiConfiguration
const hasKey = config
? [
config.apiKey,
config.glamaApiKey,
config.openRouterApiKey,
config.awsRegion,
config.vertexProjectId,
config.openAiApiKey,
config.ollamaModelId,
config.lmStudioModelId,
config.geminiApiKey,
config.openAiNativeApiKey,
config.deepSeekApiKey,
].some((key) => key !== undefined)
: false
const hasKey = checkExistKey(config)
setShowWelcome(!hasKey)
setDidHydrateState(true)
break
@@ -162,8 +158,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setMcpServers(message.mcpServers ?? [])
break
}
case "listApiConfig": {
setListApiConfigMeta(message.listApiConfig ?? [])
break
}
}
}, [])
}, [setListApiConfigMeta])
useEvent("message", handleMessage)
@@ -208,7 +208,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })),
setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })),
setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })),
setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value }))
setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })),
setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })),
setListApiConfigMeta
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>