ApiConfigManager.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
  2. import { memo, useEffect, useRef, useState } from "react"
  3. import { useAppTranslation } from "@/i18n/TranslationContext"
  4. import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
  5. import { Dropdown } from "vscrui"
  6. import type { DropdownOption } from "vscrui"
  7. import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
  8. import { Button, Input } from "../ui"
  9. interface ApiConfigManagerProps {
  10. currentApiConfigName?: string
  11. listApiConfigMeta?: ApiConfigMeta[]
  12. onSelectConfig: (configName: string) => void
  13. onDeleteConfig: (configName: string) => void
  14. onRenameConfig: (oldName: string, newName: string) => void
  15. onUpsertConfig: (configName: string) => void
  16. }
  17. const ApiConfigManager = ({
  18. currentApiConfigName = "",
  19. listApiConfigMeta = [],
  20. onSelectConfig,
  21. onDeleteConfig,
  22. onRenameConfig,
  23. onUpsertConfig,
  24. }: ApiConfigManagerProps) => {
  25. const { t } = useAppTranslation()
  26. const [isRenaming, setIsRenaming] = useState(false)
  27. const [isCreating, setIsCreating] = useState(false)
  28. const [inputValue, setInputValue] = useState("")
  29. const [newProfileName, setNewProfileName] = useState("")
  30. const [error, setError] = useState<string | null>(null)
  31. const inputRef = useRef<any>(null)
  32. const newProfileInputRef = useRef<any>(null)
  33. const validateName = (name: string, isNewProfile: boolean): string | null => {
  34. const trimmed = name.trim()
  35. if (!trimmed) return t("settings:providers.nameEmpty")
  36. const nameExists = listApiConfigMeta?.some((config) => config.name.toLowerCase() === trimmed.toLowerCase())
  37. // For new profiles, any existing name is invalid
  38. if (isNewProfile && nameExists) {
  39. return t("settings:providers.nameExists")
  40. }
  41. // For rename, only block if trying to rename to a different existing profile
  42. if (!isNewProfile && nameExists && trimmed.toLowerCase() !== currentApiConfigName?.toLowerCase()) {
  43. return t("settings:providers.nameExists")
  44. }
  45. return null
  46. }
  47. const resetCreateState = () => {
  48. setIsCreating(false)
  49. setNewProfileName("")
  50. setError(null)
  51. }
  52. const resetRenameState = () => {
  53. setIsRenaming(false)
  54. setInputValue("")
  55. setError(null)
  56. }
  57. // Focus input when entering rename mode
  58. useEffect(() => {
  59. if (isRenaming) {
  60. const timeoutId = setTimeout(() => inputRef.current?.focus(), 0)
  61. return () => clearTimeout(timeoutId)
  62. }
  63. }, [isRenaming])
  64. // Focus input when opening new dialog
  65. useEffect(() => {
  66. if (isCreating) {
  67. const timeoutId = setTimeout(() => newProfileInputRef.current?.focus(), 0)
  68. return () => clearTimeout(timeoutId)
  69. }
  70. }, [isCreating])
  71. // Reset state when current profile changes
  72. useEffect(() => {
  73. resetCreateState()
  74. resetRenameState()
  75. }, [currentApiConfigName])
  76. const handleAdd = () => {
  77. resetCreateState()
  78. setIsCreating(true)
  79. }
  80. const handleStartRename = () => {
  81. setIsRenaming(true)
  82. setInputValue(currentApiConfigName || "")
  83. setError(null)
  84. }
  85. const handleCancel = () => {
  86. resetRenameState()
  87. }
  88. const handleSave = () => {
  89. const trimmedValue = inputValue.trim()
  90. const error = validateName(trimmedValue, false)
  91. if (error) {
  92. setError(error)
  93. return
  94. }
  95. if (isRenaming && currentApiConfigName) {
  96. if (currentApiConfigName === trimmedValue) {
  97. resetRenameState()
  98. return
  99. }
  100. onRenameConfig(currentApiConfigName, trimmedValue)
  101. }
  102. resetRenameState()
  103. }
  104. const handleNewProfileSave = () => {
  105. const trimmedValue = newProfileName.trim()
  106. const error = validateName(trimmedValue, true)
  107. if (error) {
  108. setError(error)
  109. return
  110. }
  111. onUpsertConfig(trimmedValue)
  112. resetCreateState()
  113. }
  114. const handleDelete = () => {
  115. if (!currentApiConfigName || !listApiConfigMeta || listApiConfigMeta.length <= 1) return
  116. // Let the extension handle both deletion and selection
  117. onDeleteConfig(currentApiConfigName)
  118. }
  119. const isOnlyProfile = listApiConfigMeta?.length === 1
  120. return (
  121. <div className="flex flex-col gap-1">
  122. <label htmlFor="config-profile">
  123. <span className="font-medium">{t("settings:providers.configProfile")}</span>
  124. </label>
  125. {isRenaming ? (
  126. <div
  127. data-testid="rename-form"
  128. style={{ display: "flex", gap: "4px", alignItems: "center", flexDirection: "column" }}>
  129. <div style={{ display: "flex", gap: "4px", alignItems: "center", width: "100%" }}>
  130. <VSCodeTextField
  131. ref={inputRef}
  132. value={inputValue}
  133. onInput={(e: unknown) => {
  134. const target = e as { target: { value: string } }
  135. setInputValue(target.target.value)
  136. setError(null)
  137. }}
  138. placeholder={t("settings:providers.enterNewName")}
  139. style={{ flexGrow: 1 }}
  140. onKeyDown={(e: unknown) => {
  141. const event = e as { key: string }
  142. if (event.key === "Enter" && inputValue.trim()) {
  143. handleSave()
  144. } else if (event.key === "Escape") {
  145. handleCancel()
  146. }
  147. }}
  148. />
  149. <VSCodeButton
  150. appearance="icon"
  151. disabled={!inputValue.trim()}
  152. onClick={handleSave}
  153. title={t("settings:common.save")}
  154. data-testid="save-rename-button"
  155. style={{
  156. padding: 0,
  157. margin: 0,
  158. height: "28px",
  159. width: "28px",
  160. minWidth: "28px",
  161. }}>
  162. <span className="codicon codicon-check" />
  163. </VSCodeButton>
  164. <VSCodeButton
  165. appearance="icon"
  166. onClick={handleCancel}
  167. title={t("settings:common.cancel")}
  168. data-testid="cancel-rename-button"
  169. style={{
  170. padding: 0,
  171. margin: 0,
  172. height: "28px",
  173. width: "28px",
  174. minWidth: "28px",
  175. }}>
  176. <span className="codicon codicon-close" />
  177. </VSCodeButton>
  178. </div>
  179. {error && (
  180. <p className="text-red-500 text-sm mt-2" data-testid="error-message">
  181. {error}
  182. </p>
  183. )}
  184. </div>
  185. ) : (
  186. <>
  187. <div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
  188. <Dropdown
  189. id="config-profile"
  190. value={currentApiConfigName}
  191. onChange={(value: unknown) => {
  192. onSelectConfig((value as DropdownOption).value)
  193. }}
  194. role="combobox"
  195. options={listApiConfigMeta.map((config) => ({
  196. value: config.name,
  197. label: config.name,
  198. }))}
  199. className="w-full"
  200. />
  201. <VSCodeButton
  202. appearance="icon"
  203. onClick={handleAdd}
  204. title={t("settings:providers.addProfile")}
  205. data-testid="add-profile-button"
  206. style={{
  207. padding: 0,
  208. margin: 0,
  209. height: "28px",
  210. width: "28px",
  211. minWidth: "28px",
  212. }}>
  213. <span className="codicon codicon-add" />
  214. </VSCodeButton>
  215. {currentApiConfigName && (
  216. <>
  217. <VSCodeButton
  218. appearance="icon"
  219. onClick={handleStartRename}
  220. title={t("settings:providers.renameProfile")}
  221. data-testid="rename-profile-button"
  222. style={{
  223. padding: 0,
  224. margin: 0,
  225. height: "28px",
  226. width: "28px",
  227. minWidth: "28px",
  228. }}>
  229. <span className="codicon codicon-edit" />
  230. </VSCodeButton>
  231. <VSCodeButton
  232. appearance="icon"
  233. onClick={handleDelete}
  234. title={
  235. isOnlyProfile
  236. ? t("settings:providers.cannotDeleteOnlyProfile")
  237. : t("settings:providers.deleteProfile")
  238. }
  239. data-testid="delete-profile-button"
  240. disabled={isOnlyProfile}
  241. style={{
  242. padding: 0,
  243. margin: 0,
  244. height: "28px",
  245. width: "28px",
  246. minWidth: "28px",
  247. }}>
  248. <span className="codicon codicon-trash" />
  249. </VSCodeButton>
  250. </>
  251. )}
  252. </div>
  253. <p
  254. style={{
  255. fontSize: "12px",
  256. margin: "5px 0 12px",
  257. color: "var(--vscode-descriptionForeground)",
  258. }}>
  259. {t("settings:providers.description")}
  260. </p>
  261. </>
  262. )}
  263. <Dialog
  264. open={isCreating}
  265. onOpenChange={(open: boolean) => {
  266. if (open) {
  267. setIsCreating(true)
  268. setNewProfileName("")
  269. setError(null)
  270. } else {
  271. resetCreateState()
  272. }
  273. }}
  274. aria-labelledby="new-profile-title">
  275. <DialogContent className="p-4 max-w-sm">
  276. <DialogTitle>{t("settings:providers.newProfile")}</DialogTitle>
  277. <Input
  278. ref={newProfileInputRef}
  279. value={newProfileName}
  280. onInput={(e: unknown) => {
  281. const target = e as { target: { value: string } }
  282. setNewProfileName(target.target.value)
  283. setError(null)
  284. }}
  285. placeholder={t("settings:providers.enterProfileName")}
  286. data-testid="new-profile-input"
  287. style={{ width: "100%" }}
  288. onKeyDown={(e: unknown) => {
  289. const event = e as { key: string }
  290. if (event.key === "Enter" && newProfileName.trim()) {
  291. handleNewProfileSave()
  292. } else if (event.key === "Escape") {
  293. resetCreateState()
  294. }
  295. }}
  296. />
  297. {error && (
  298. <p className="text-red-500 text-sm mt-2" data-testid="error-message">
  299. {error}
  300. </p>
  301. )}
  302. <div className="flex justify-end gap-2 mt-4">
  303. <Button variant="secondary" onClick={resetCreateState} data-testid="cancel-new-profile-button">
  304. {t("settings:common.cancel")}
  305. </Button>
  306. <Button
  307. variant="default"
  308. disabled={!newProfileName.trim()}
  309. onClick={handleNewProfileSave}
  310. data-testid="create-profile-button">
  311. {t("settings:providers.createProfile")}
  312. </Button>
  313. </div>
  314. </DialogContent>
  315. </Dialog>
  316. </div>
  317. )
  318. }
  319. export default memo(ApiConfigManager)