Skip to content

Commit 9bace96

Browse files
authored
Merge pull request #853 from RooVetGit/add_profile_modal
Better UX for adding new API config profiles
2 parents 14d7691 + 69d5872 commit 9bace96

File tree

3 files changed

+341
-78
lines changed

3 files changed

+341
-78
lines changed

.changeset/dirty-coins-exist.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Improve the user experience for adding a new configuration profile

webview-ui/src/components/settings/ApiConfigManager.tsx

Lines changed: 195 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { memo, useEffect, useRef, useState } from "react"
33
import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
44
import { Dropdown } from "vscrui"
55
import type { DropdownOption } from "vscrui"
6+
import { Dialog, DialogContent } from "../ui/dialog"
67

78
interface ApiConfigManagerProps {
89
currentApiConfigName?: string
@@ -21,55 +22,113 @@ const ApiConfigManager = ({
2122
onRenameConfig,
2223
onUpsertConfig,
2324
}: ApiConfigManagerProps) => {
24-
const [editState, setEditState] = useState<"new" | "rename" | null>(null)
25+
const [isRenaming, setIsRenaming] = useState(false)
26+
const [isCreating, setIsCreating] = useState(false)
2527
const [inputValue, setInputValue] = useState("")
26-
const inputRef = useRef<HTMLInputElement>()
28+
const [newProfileName, setNewProfileName] = useState("")
29+
const [error, setError] = useState<string | null>(null)
30+
const inputRef = useRef<any>(null)
31+
const newProfileInputRef = useRef<any>(null)
2732

28-
// Focus input when entering edit mode
33+
const validateName = (name: string, isNewProfile: boolean): string | null => {
34+
const trimmed = name.trim()
35+
if (!trimmed) return "Name cannot be empty"
36+
37+
const nameExists = listApiConfigMeta?.some((config) => config.name.toLowerCase() === trimmed.toLowerCase())
38+
39+
// For new profiles, any existing name is invalid
40+
if (isNewProfile && nameExists) {
41+
return "A profile with this name already exists"
42+
}
43+
44+
// For rename, only block if trying to rename to a different existing profile
45+
if (!isNewProfile && nameExists && trimmed.toLowerCase() !== currentApiConfigName?.toLowerCase()) {
46+
return "A profile with this name already exists"
47+
}
48+
49+
return null
50+
}
51+
52+
const resetCreateState = () => {
53+
setIsCreating(false)
54+
setNewProfileName("")
55+
setError(null)
56+
}
57+
58+
const resetRenameState = () => {
59+
setIsRenaming(false)
60+
setInputValue("")
61+
setError(null)
62+
}
63+
64+
// Focus input when entering rename mode
2965
useEffect(() => {
30-
if (editState) {
31-
setTimeout(() => inputRef.current?.focus(), 0)
66+
if (isRenaming) {
67+
const timeoutId = setTimeout(() => inputRef.current?.focus(), 0)
68+
return () => clearTimeout(timeoutId)
3269
}
33-
}, [editState])
70+
}, [isRenaming])
3471

35-
// Reset edit state when current profile changes
72+
// Focus input when opening new dialog
3673
useEffect(() => {
37-
setEditState(null)
38-
setInputValue("")
74+
if (isCreating) {
75+
const timeoutId = setTimeout(() => newProfileInputRef.current?.focus(), 0)
76+
return () => clearTimeout(timeoutId)
77+
}
78+
}, [isCreating])
79+
80+
// Reset state when current profile changes
81+
useEffect(() => {
82+
resetCreateState()
83+
resetRenameState()
3984
}, [currentApiConfigName])
4085

4186
const handleAdd = () => {
42-
const newConfigName = currentApiConfigName + " (copy)"
43-
onUpsertConfig(newConfigName)
87+
resetCreateState()
88+
setIsCreating(true)
4489
}
4590

4691
const handleStartRename = () => {
47-
setEditState("rename")
92+
setIsRenaming(true)
4893
setInputValue(currentApiConfigName || "")
94+
setError(null)
4995
}
5096

5197
const handleCancel = () => {
52-
setEditState(null)
53-
setInputValue("")
98+
resetRenameState()
5499
}
55100

56101
const handleSave = () => {
57102
const trimmedValue = inputValue.trim()
58-
if (!trimmedValue) return
103+
const error = validateName(trimmedValue, false)
59104

60-
if (editState === "new") {
61-
onUpsertConfig(trimmedValue)
62-
} else if (editState === "rename" && currentApiConfigName) {
105+
if (error) {
106+
setError(error)
107+
return
108+
}
109+
110+
if (isRenaming && currentApiConfigName) {
63111
if (currentApiConfigName === trimmedValue) {
64-
setEditState(null)
65-
setInputValue("")
112+
resetRenameState()
66113
return
67114
}
68115
onRenameConfig(currentApiConfigName, trimmedValue)
69116
}
70117

71-
setEditState(null)
72-
setInputValue("")
118+
resetRenameState()
119+
}
120+
121+
const handleNewProfileSave = () => {
122+
const trimmedValue = newProfileName.trim()
123+
const error = validateName(trimmedValue, true)
124+
125+
if (error) {
126+
setError(error)
127+
return
128+
}
129+
130+
onUpsertConfig(trimmedValue)
131+
resetCreateState()
73132
}
74133

75134
const handleDelete = () => {
@@ -93,49 +152,63 @@ const ApiConfigManager = ({
93152
<span style={{ fontWeight: "500" }}>Configuration Profile</span>
94153
</label>
95154

96-
{editState ? (
97-
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
98-
<VSCodeTextField
99-
ref={inputRef as any}
100-
value={inputValue}
101-
onInput={(e: any) => setInputValue(e.target.value)}
102-
placeholder={editState === "new" ? "Enter profile name" : "Enter new name"}
103-
style={{ flexGrow: 1 }}
104-
onKeyDown={(e: any) => {
105-
if (e.key === "Enter" && inputValue.trim()) {
106-
handleSave()
107-
} else if (e.key === "Escape") {
108-
handleCancel()
109-
}
110-
}}
111-
/>
112-
<VSCodeButton
113-
appearance="icon"
114-
disabled={!inputValue.trim()}
115-
onClick={handleSave}
116-
title="Save"
117-
style={{
118-
padding: 0,
119-
margin: 0,
120-
height: "28px",
121-
width: "28px",
122-
minWidth: "28px",
123-
}}>
124-
<span className="codicon codicon-check" />
125-
</VSCodeButton>
126-
<VSCodeButton
127-
appearance="icon"
128-
onClick={handleCancel}
129-
title="Cancel"
130-
style={{
131-
padding: 0,
132-
margin: 0,
133-
height: "28px",
134-
width: "28px",
135-
minWidth: "28px",
136-
}}>
137-
<span className="codicon codicon-close" />
138-
</VSCodeButton>
155+
{isRenaming ? (
156+
<div
157+
data-testid="rename-form"
158+
style={{ display: "flex", gap: "4px", alignItems: "center", flexDirection: "column" }}>
159+
<div style={{ display: "flex", gap: "4px", alignItems: "center", width: "100%" }}>
160+
<VSCodeTextField
161+
ref={inputRef}
162+
value={inputValue}
163+
onInput={(e: unknown) => {
164+
const target = e as { target: { value: string } }
165+
setInputValue(target.target.value)
166+
setError(null)
167+
}}
168+
placeholder="Enter new name"
169+
style={{ flexGrow: 1 }}
170+
onKeyDown={(e: unknown) => {
171+
const event = e as { key: string }
172+
if (event.key === "Enter" && inputValue.trim()) {
173+
handleSave()
174+
} else if (event.key === "Escape") {
175+
handleCancel()
176+
}
177+
}}
178+
/>
179+
<VSCodeButton
180+
appearance="icon"
181+
disabled={!inputValue.trim()}
182+
onClick={handleSave}
183+
title="Save"
184+
style={{
185+
padding: 0,
186+
margin: 0,
187+
height: "28px",
188+
width: "28px",
189+
minWidth: "28px",
190+
}}>
191+
<span className="codicon codicon-check" />
192+
</VSCodeButton>
193+
<VSCodeButton
194+
appearance="icon"
195+
onClick={handleCancel}
196+
title="Cancel"
197+
style={{
198+
padding: 0,
199+
margin: 0,
200+
height: "28px",
201+
width: "28px",
202+
minWidth: "28px",
203+
}}>
204+
<span className="codicon codicon-close" />
205+
</VSCodeButton>
206+
</div>
207+
{error && (
208+
<p className="text-red-500 text-sm mt-2" data-testid="error-message">
209+
{error}
210+
</p>
211+
)}
139212
</div>
140213
) : (
141214
<>
@@ -211,6 +284,63 @@ const ApiConfigManager = ({
211284
</p>
212285
</>
213286
)}
287+
288+
<Dialog
289+
open={isCreating}
290+
onOpenChange={(open: boolean) => {
291+
if (open) {
292+
setIsCreating(true)
293+
setNewProfileName("")
294+
setError(null)
295+
} else {
296+
resetCreateState()
297+
}
298+
}}
299+
aria-labelledby="new-profile-title">
300+
<DialogContent className="p-4 max-w-sm">
301+
<h2 id="new-profile-title" className="text-lg font-semibold mb-4">
302+
New Configuration Profile
303+
</h2>
304+
<button className="absolute right-4 top-4" aria-label="Close dialog" onClick={resetCreateState}>
305+
<span className="codicon codicon-close" />
306+
</button>
307+
<VSCodeTextField
308+
ref={newProfileInputRef}
309+
value={newProfileName}
310+
onInput={(e: unknown) => {
311+
const target = e as { target: { value: string } }
312+
setNewProfileName(target.target.value)
313+
setError(null)
314+
}}
315+
placeholder="Enter profile name"
316+
style={{ width: "100%" }}
317+
onKeyDown={(e: unknown) => {
318+
const event = e as { key: string }
319+
if (event.key === "Enter" && newProfileName.trim()) {
320+
handleNewProfileSave()
321+
} else if (event.key === "Escape") {
322+
resetCreateState()
323+
}
324+
}}
325+
/>
326+
{error && (
327+
<p className="text-red-500 text-sm mt-2" data-testid="error-message">
328+
{error}
329+
</p>
330+
)}
331+
<div className="flex justify-end gap-2 mt-4">
332+
<VSCodeButton appearance="secondary" onClick={resetCreateState}>
333+
Cancel
334+
</VSCodeButton>
335+
<VSCodeButton
336+
appearance="primary"
337+
disabled={!newProfileName.trim()}
338+
onClick={handleNewProfileSave}>
339+
Create Profile
340+
</VSCodeButton>
341+
</div>
342+
</DialogContent>
343+
</Dialog>
214344
</div>
215345
</div>
216346
)

0 commit comments

Comments
 (0)