11import { VSCodeButton , VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
2- import { memo , useEffect , useRef , useState } from "react"
2+ import { memo , useEffect , useReducer , useRef } from "react"
33import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
44import { Dropdown } from "vscrui"
55import type { DropdownOption } from "vscrui"
6+ import { Dialog , DialogContent } from "../ui/dialog"
67
78interface ApiConfigManagerProps {
89 currentApiConfigName ?: string
@@ -13,6 +14,86 @@ interface ApiConfigManagerProps {
1314 onUpsertConfig : ( configName : string ) => void
1415}
1516
17+ type State = {
18+ isRenaming : boolean
19+ isCreating : boolean
20+ inputValue : string
21+ newProfileName : string
22+ error : string | null
23+ }
24+
25+ type Action =
26+ | { type : "START_RENAME" ; payload : string }
27+ | { type : "CANCEL_EDIT" }
28+ | { type : "SET_INPUT" ; payload : string }
29+ | { type : "SET_NEW_NAME" ; payload : string }
30+ | { type : "START_CREATE" }
31+ | { type : "CANCEL_CREATE" }
32+ | { type : "SET_ERROR" ; payload : string | null }
33+ | { type : "RESET_STATE" }
34+
35+ const initialState : State = {
36+ isRenaming : false ,
37+ isCreating : false ,
38+ inputValue : "" ,
39+ newProfileName : "" ,
40+ error : null ,
41+ }
42+
43+ const reducer = ( state : State , action : Action ) : State => {
44+ switch ( action . type ) {
45+ case "START_RENAME" :
46+ return {
47+ ...state ,
48+ isRenaming : true ,
49+ inputValue : action . payload ,
50+ error : null ,
51+ }
52+ case "CANCEL_EDIT" :
53+ return {
54+ ...state ,
55+ isRenaming : false ,
56+ inputValue : "" ,
57+ error : null ,
58+ }
59+ case "SET_INPUT" :
60+ return {
61+ ...state ,
62+ inputValue : action . payload ,
63+ error : null ,
64+ }
65+ case "SET_NEW_NAME" :
66+ return {
67+ ...state ,
68+ newProfileName : action . payload ,
69+ error : null ,
70+ }
71+ case "START_CREATE" :
72+ return {
73+ ...state ,
74+ isCreating : true ,
75+ newProfileName : "" ,
76+ error : null ,
77+ }
78+ case "CANCEL_CREATE" :
79+ return {
80+ ...state ,
81+ isCreating : false ,
82+ newProfileName : "" ,
83+ error : null ,
84+ }
85+ case "SET_ERROR" :
86+ return {
87+ ...state ,
88+ error : action . payload ,
89+ }
90+ case "RESET_STATE" :
91+ return initialState
92+ default :
93+ return state
94+ }
95+ }
96+
1697const ApiConfigManager = ( {
1798 currentApiConfigName = "" ,
1899 listApiConfigMeta = [ ] ,
@@ -21,55 +102,93 @@ const ApiConfigManager = ({
21102 onRenameConfig,
22103 onUpsertConfig,
23104} : ApiConfigManagerProps ) => {
24- const [ editState , setEditState ] = useState < "new" | "rename" | null > ( null )
25- const [ inputValue , setInputValue ] = useState ( "" )
26- const inputRef = useRef < HTMLInputElement > ( )
105+ const [ state , dispatch ] = useReducer ( reducer , initialState )
106+ const inputRef = useRef < any > ( null )
107+ const newProfileInputRef = useRef < any > ( null )
27108
28- // Focus input when entering edit mode
109+ const validateName = ( name : string , isNewProfile : boolean ) : string | null => {
110+ const trimmed = name . trim ( )
111+ if ( ! trimmed ) return "Name cannot be empty"
112+
113+ const nameExists = listApiConfigMeta ?. some ( ( config ) => config . name . toLowerCase ( ) === trimmed . toLowerCase ( ) )
114+
115+ // For new profiles, any existing name is invalid
116+ if ( isNewProfile && nameExists ) {
117+ return "A profile with this name already exists"
118+ }
119+
120+ // For rename, only block if trying to rename to a different existing profile
121+ if ( ! isNewProfile && nameExists && trimmed . toLowerCase ( ) !== currentApiConfigName ?. toLowerCase ( ) ) {
122+ return "A profile with this name already exists"
123+ }
124+
125+ return null
126+ }
127+
128+ // Focus input when entering rename mode
129+ useEffect ( ( ) => {
130+ if ( state . isRenaming ) {
131+ const timeoutId = setTimeout ( ( ) => inputRef . current ?. focus ( ) , 0 )
132+ return ( ) => clearTimeout ( timeoutId )
133+ }
134+ } , [ state . isRenaming ] )
135+
136+ // Focus input when opening new dialog
29137 useEffect ( ( ) => {
30- if ( editState ) {
31- setTimeout ( ( ) => inputRef . current ?. focus ( ) , 0 )
138+ if ( state . isCreating ) {
139+ const timeoutId = setTimeout ( ( ) => newProfileInputRef . current ?. focus ( ) , 0 )
140+ return ( ) => clearTimeout ( timeoutId )
32141 }
33- } , [ editState ] )
142+ } , [ state . isCreating ] )
34143
35- // Reset edit state when current profile changes
144+ // Reset state when current profile changes
36145 useEffect ( ( ) => {
37- setEditState ( null )
38- setInputValue ( "" )
146+ dispatch ( { type : "RESET_STATE" } )
39147 } , [ currentApiConfigName ] )
40148
41149 const handleAdd = ( ) => {
42- const newConfigName = currentApiConfigName + " (copy)"
43- onUpsertConfig ( newConfigName )
150+ dispatch ( { type : "START_CREATE" } )
44151 }
45152
46153 const handleStartRename = ( ) => {
47- setEditState ( "rename" )
48- setInputValue ( currentApiConfigName || "" )
154+ dispatch ( { type : "START_RENAME" , payload : currentApiConfigName || "" } )
49155 }
50156
51157 const handleCancel = ( ) => {
52- setEditState ( null )
53- setInputValue ( "" )
158+ dispatch ( { type : "CANCEL_EDIT" } )
54159 }
55160
56161 const handleSave = ( ) => {
57- const trimmedValue = inputValue . trim ( )
58- if ( ! trimmedValue ) return
162+ const trimmedValue = state . inputValue . trim ( )
163+ const error = validateName ( trimmedValue , false )
164+
165+ if ( error ) {
166+ dispatch ( { type : "SET_ERROR" , payload : error } )
167+ return
168+ }
59169
60- if ( editState === "new" ) {
61- onUpsertConfig ( trimmedValue )
62- } else if ( editState === "rename" && currentApiConfigName ) {
170+ if ( state . isRenaming && currentApiConfigName ) {
63171 if ( currentApiConfigName === trimmedValue ) {
64- setEditState ( null )
65- setInputValue ( "" )
172+ dispatch ( { type : "CANCEL_EDIT" } )
66173 return
67174 }
68175 onRenameConfig ( currentApiConfigName , trimmedValue )
69176 }
70177
71- setEditState ( null )
72- setInputValue ( "" )
178+ dispatch ( { type : "CANCEL_EDIT" } )
179+ }
180+
181+ const handleNewProfileSave = ( ) => {
182+ const trimmedValue = state . newProfileName . trim ( )
183+ const error = validateName ( trimmedValue , true )
184+
185+ if ( error ) {
186+ dispatch ( { type : "SET_ERROR" , payload : error } )
187+ return
188+ }
189+
190+ onUpsertConfig ( trimmedValue )
191+ dispatch ( { type : "CANCEL_CREATE" } )
73192 }
74193
75194 const handleDelete = ( ) => {
@@ -93,49 +212,62 @@ const ApiConfigManager = ({
93212 < span style = { { fontWeight : "500" } } > Configuration Profile</ span >
94213 </ label >
95214
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 >
215+ { state . isRenaming ? (
216+ < div
217+ data-testid = "rename-form"
218+ style = { { display : "flex" , gap : "4px" , alignItems : "center" , flexDirection : "column" } } >
219+ < div style = { { display : "flex" , gap : "4px" , alignItems : "center" , width : "100%" } } >
220+ < VSCodeTextField
221+ ref = { inputRef }
222+ value = { state . inputValue }
223+ onInput = { ( e : unknown ) => {
224+ const target = e as { target : { value : string } }
225+ dispatch ( { type : "SET_INPUT" , payload : target . target . value } )
226+ } }
227+ placeholder = "Enter new name"
228+ style = { { flexGrow : 1 } }
229+ onKeyDown = { ( e : unknown ) => {
230+ const event = e as { key : string }
231+ if ( event . key === "Enter" && state . inputValue . trim ( ) ) {
232+ handleSave ( )
233+ } else if ( event . key === "Escape" ) {
234+ handleCancel ( )
235+ }
236+ } }
237+ />
238+ < VSCodeButton
239+ appearance = "icon"
240+ disabled = { ! state . inputValue . trim ( ) }
241+ onClick = { handleSave }
242+ title = "Save"
243+ style = { {
244+ padding : 0 ,
245+ margin : 0 ,
246+ height : "28px" ,
247+ width : "28px" ,
248+ minWidth : "28px" ,
249+ } } >
250+ < span className = "codicon codicon-check" />
251+ </ VSCodeButton >
252+ < VSCodeButton
253+ appearance = "icon"
254+ onClick = { handleCancel }
255+ title = "Cancel"
256+ style = { {
257+ padding : 0 ,
258+ margin : 0 ,
259+ height : "28px" ,
260+ width : "28px" ,
261+ minWidth : "28px" ,
262+ } } >
263+ < span className = "codicon codicon-close" />
264+ </ VSCodeButton >
265+ </ div >
266+ { state . error && (
267+ < p className = "text-red-500 text-sm mt-2" data-testid = "error-message" >
268+ { state . error }
269+ </ p >
270+ ) }
139271 </ div >
140272 ) : (
141273 < >
@@ -211,6 +343,57 @@ const ApiConfigManager = ({
211343 </ p >
212344 </ >
213345 ) }
346+
347+ < Dialog
348+ open = { state . isCreating }
349+ onOpenChange = { ( open : boolean ) => dispatch ( { type : open ? "START_CREATE" : "CANCEL_CREATE" } ) }
350+ aria-labelledby = "new-profile-title" >
351+ < DialogContent className = "p-4 max-w-sm" >
352+ < h2 id = "new-profile-title" className = "text-lg font-semibold mb-4" >
353+ New Configuration Profile
354+ </ h2 >
355+ < button
356+ className = "absolute right-4 top-4"
357+ aria-label = "Close dialog"
358+ onClick = { ( ) => dispatch ( { type : "CANCEL_CREATE" } ) } >
359+ < span className = "codicon codicon-close" />
360+ </ button >
361+ < VSCodeTextField
362+ ref = { newProfileInputRef }
363+ value = { state . newProfileName }
364+ onInput = { ( e : unknown ) => {
365+ const target = e as { target : { value : string } }
366+ dispatch ( { type : "SET_NEW_NAME" , payload : target . target . value } )
367+ } }
368+ placeholder = "Enter profile name"
369+ style = { { width : "100%" } }
370+ onKeyDown = { ( e : unknown ) => {
371+ const event = e as { key : string }
372+ if ( event . key === "Enter" && state . newProfileName . trim ( ) ) {
373+ handleNewProfileSave ( )
374+ } else if ( event . key === "Escape" ) {
375+ dispatch ( { type : "CANCEL_CREATE" } )
376+ }
377+ } }
378+ />
379+ { state . error && (
380+ < p className = "text-red-500 text-sm mt-2" data-testid = "error-message" >
381+ { state . error }
382+ </ p >
383+ ) }
384+ < div className = "flex justify-end gap-2 mt-4" >
385+ < VSCodeButton appearance = "secondary" onClick = { ( ) => dispatch ( { type : "CANCEL_CREATE" } ) } >
386+ Cancel
387+ </ VSCodeButton >
388+ < VSCodeButton
389+ appearance = "primary"
390+ disabled = { ! state . newProfileName . trim ( ) }
391+ onClick = { handleNewProfileSave } >
392+ Create Profile
393+ </ VSCodeButton >
394+ </ div >
395+ </ DialogContent >
396+ </ Dialog >
214397 </ div >
215398 </ div >
216399 )
0 commit comments