1- import { ArrowRight , FileIcon , Loader2 , UploadCloud } from "lucide-react" ;
1+ import { ArrowRight , CheckCircle2 , FileIcon , Loader2 , Trash2 , UploadCloud } from "lucide-react" ;
22import { type ChangeEvent , type DragEvent , useCallback , useRef , useState } from "react" ;
33import { SecretInput } from "@/components/ui/SecretInput" ;
44import { Button } from "@/components/ui/shadcn/button" ;
@@ -8,17 +8,16 @@ import { cn } from "@/libs/shadcn/utils";
88import type { CredentialField } from "../types" ;
99
1010/**
11- * Per-field state for `file_upload` fields. Tracks the files the user picked,
12- * whether an upload is in flight, which paths the backend confirmed, and any
13- * error from the last upload attempt. One instance is keyed per field, so a
14- * form can hypothetically carry multiple upload fields — today only DuckDB
15- * uses this.
11+ * Per-field state for `file_upload` fields. Files the user has picked stay in
12+ * `selected` until the user clicks the form CTA — only then does the actual
13+ * upload happen. After a successful upload, the server-confirmed paths land
14+ * in `uploadedPaths`. One instance is keyed per field, so a form can
15+ * hypothetically carry multiple upload fields — today only DuckDB uses this.
1616 */
1717interface FileUploadState {
1818 selected : File [ ] ;
1919 uploading : boolean ;
2020 uploadedPaths : string [ ] ;
21- subdir ?: string ;
2221 error ?: string ;
2322}
2423
@@ -34,16 +33,24 @@ interface CredentialFormProps {
3433 onSubmit : ( values : Record < string , string > ) => void ;
3534 disabled ?: boolean ;
3635 initialValues ?: Record < string , string > ;
36+ /**
37+ * Pre-populated uploaded paths per `file_upload` field key. Lets the form
38+ * remember files an earlier visit already sent so the user isn't blocked
39+ * with a disabled CTA after navigating back.
40+ */
41+ initialUploadedFiles ?: Record < string , string [ ] > ;
3742 /**
3843 * Inline error surfaced below the form. Used by the GitHub onboarding flow
3944 * to show a failed connection-test message so the user can fix typos and
4045 * retry without losing what they've already typed.
4146 */
4247 errorMessage ?: string ;
4348 /**
44- * Called when the user picks files for a `file_upload` field. Should upload
45- * the files and resolve with the server-assigned subdir + file paths, or
46- * reject (the form surfaces the error inline).
49+ * Invoked when the user clicks the form CTA with pending file selections.
50+ * Should upload the files and resolve with the server-assigned subdir + file
51+ * paths, or reject (the form surfaces the error inline). Files are NOT
52+ * uploaded on drag/drop or pick — the form holds them locally so the user
53+ * can remove mistakes before committing.
4754 */
4855 onFileUpload ?: ( files : File [ ] ) => Promise < FileUploadResult | null > ;
4956}
@@ -54,6 +61,7 @@ export default function CredentialForm({
5461 onSubmit,
5562 disabled,
5663 initialValues,
64+ initialUploadedFiles,
5765 errorMessage,
5866 onFileUpload
5967} : CredentialFormProps ) {
@@ -70,7 +78,15 @@ export default function CredentialForm({
7078 return initial ;
7179 } ) ;
7280
73- const [ uploads , setUploads ] = useState < Record < string , FileUploadState > > ( { } ) ;
81+ const [ uploads , setUploads ] = useState < Record < string , FileUploadState > > ( ( ) => {
82+ if ( ! initialUploadedFiles ) return { } ;
83+ const seeded : Record < string , FileUploadState > = { } ;
84+ for ( const [ key , paths ] of Object . entries ( initialUploadedFiles ) ) {
85+ if ( paths . length === 0 ) continue ;
86+ seeded [ key ] = { selected : [ ] , uploading : false , uploadedPaths : paths } ;
87+ }
88+ return seeded ;
89+ } ) ;
7490
7591 const handleChange = useCallback ( ( key : string , value : string ) => {
7692 setValues ( ( prev ) => ( { ...prev , [ key ] : value } ) ) ;
@@ -81,12 +97,16 @@ export default function CredentialForm({
8197 if ( ! field . required ) return true ;
8298 if ( field . type === "file_upload" ) {
8399 const state = uploads [ field . key ] ;
84- return ! ! state && state . uploadedPaths . length > 0 && ! state . uploading ;
100+ if ( ! state || state . uploading ) return false ;
101+ // Pending picks only count if a handler can actually upload them —
102+ // otherwise the CTA would submit with the field value still empty.
103+ if ( state . selected . length > 0 && onFileUpload ) return true ;
104+ return state . uploadedPaths . length > 0 ;
85105 }
86106 const v = values [ field . key ] ;
87107 return typeof v === "string" && v . trim ( ) !== "" ;
88108 } ,
89- [ uploads , values ]
109+ [ uploads , values , onFileUpload ]
90110 ) ;
91111
92112 /** Per-field validation message, or undefined when valid / empty. */
@@ -111,67 +131,105 @@ export default function CredentialForm({
111131 const allValid = fields . every ( ( f ) => validationError ( f ) === undefined ) ;
112132 const anyUploading = Object . values ( uploads ) . some ( ( s ) => s . uploading ) ;
113133
114- const handleSubmit = useCallback ( ( ) => {
115- if ( ! allRequiredFilled || ! allValid || anyUploading ) return ;
116- onSubmit ( values ) ;
117- } , [ allRequiredFilled , allValid , anyUploading , onSubmit , values ] ) ;
118-
119- const runUpload = useCallback (
120- async ( fieldKey : string , files : File [ ] ) => {
121- if ( files . length === 0 || ! onFileUpload ) return ;
122- setUploads ( ( prev ) => ( {
134+ const addSelectedFiles = useCallback ( ( fieldKey : string , files : File [ ] ) => {
135+ if ( files . length === 0 ) return ;
136+ setUploads ( ( prev ) => {
137+ const current = prev [ fieldKey ] ?? {
138+ selected : [ ] ,
139+ uploading : false ,
140+ uploadedPaths : [ ]
141+ } ;
142+ // Drop dupes (same name + size) so re-picking the same file doesn't
143+ // create a phantom row the user can't tell apart from the original.
144+ const seen = new Set ( current . selected . map ( ( f ) => `${ f . name } :${ f . size } ` ) ) ;
145+ const deduped = files . filter ( ( f ) => ! seen . has ( `${ f . name } :${ f . size } ` ) ) ;
146+ return {
123147 ...prev ,
124148 [ fieldKey ] : {
125- selected : [ ...( prev [ fieldKey ] ?. selected ?? [ ] ) , ...files ] ,
126- uploading : true ,
127- uploadedPaths : prev [ fieldKey ] ?. uploadedPaths ?? [ ] ,
128- subdir : prev [ fieldKey ] ?. subdir ,
149+ ...current ,
150+ selected : [ ...current . selected , ...deduped ] ,
129151 error : undefined
130152 }
153+ } ;
154+ } ) ;
155+ } , [ ] ) ;
156+
157+ const removeSelectedFile = useCallback ( ( fieldKey : string , index : number ) => {
158+ setUploads ( ( prev ) => {
159+ const current = prev [ fieldKey ] ;
160+ if ( ! current ) return prev ;
161+ const next = [ ...current . selected ] ;
162+ next . splice ( index , 1 ) ;
163+ return { ...prev , [ fieldKey ] : { ...current , selected : next } } ;
164+ } ) ;
165+ } , [ ] ) ;
166+
167+ const handleSubmit = useCallback ( async ( ) => {
168+ if ( ! allRequiredFilled || ! allValid || anyUploading ) return ;
169+
170+ // Upload any pending file_upload selections first, then submit with the
171+ // server-chosen subdir as the field value (DuckDB uses this as
172+ // `file_search_path`). Bail out without submitting if an upload fails.
173+ let updatedValues = values ;
174+ for ( const field of fields ) {
175+ if ( field . type !== "file_upload" ) continue ;
176+ const state = uploads [ field . key ] ;
177+ if ( ! state ?. selected . length || ! onFileUpload ) continue ;
178+
179+ const filesToUpload = state . selected ;
180+ setUploads ( ( prev ) => ( {
181+ ...prev ,
182+ [ field . key ] : { ...prev [ field . key ] ! , uploading : true , error : undefined }
131183 } ) ) ;
184+
132185 try {
133- const result = await onFileUpload ( files ) ;
134- setUploads ( ( prev ) => {
135- const current = prev [ fieldKey ] ;
136- if ( ! current ) return prev ;
137- if ( ! result ) {
138- return {
139- ...prev ,
140- [ fieldKey ] : { ...current , uploading : false , error : "Upload failed." }
141- } ;
142- }
143- return {
186+ const result = await onFileUpload ( filesToUpload ) ;
187+ if ( ! result ) {
188+ setUploads ( ( prev ) => ( {
144189 ...prev ,
145- [ fieldKey ] : {
146- ...current ,
147- uploading : false ,
148- uploadedPaths : [ ...current . uploadedPaths , ...result . files ] ,
149- subdir : result . subdir
150- }
151- } ;
152- } ) ;
153- if ( result ) {
154- // Seed the form value with the server-chosen subdir so the parent
155- // gets a meaningful value on submit (e.g. ".db" for DuckDB).
156- handleChange ( fieldKey , result . subdir ) ;
190+ [ field . key ] : { ...prev [ field . key ] ! , uploading : false , error : "Upload failed." }
191+ } ) ) ;
192+ return ;
157193 }
158- } catch ( err ) {
159194 setUploads ( ( prev ) => {
160- const current = prev [ fieldKey ] ;
195+ const current = prev [ field . key ] ;
161196 if ( ! current ) return prev ;
162197 return {
163198 ...prev ,
164- [ fieldKey ] : {
165- ... current ,
199+ [ field . key ] : {
200+ selected : [ ] ,
166201 uploading : false ,
167- error : err instanceof Error ? err . message : "Upload failed."
202+ uploadedPaths : [ ... current . uploadedPaths , ... result . files ]
168203 }
169204 } ;
170205 } ) ;
206+ updatedValues = { ...updatedValues , [ field . key ] : result . subdir } ;
207+ handleChange ( field . key , result . subdir ) ;
208+ } catch ( err ) {
209+ setUploads ( ( prev ) => ( {
210+ ...prev ,
211+ [ field . key ] : {
212+ ...prev [ field . key ] ! ,
213+ uploading : false ,
214+ error : err instanceof Error ? err . message : "Upload failed."
215+ }
216+ } ) ) ;
217+ return ;
171218 }
172- } ,
173- [ onFileUpload , handleChange ]
174- ) ;
219+ }
220+
221+ onSubmit ( updatedValues ) ;
222+ } , [
223+ allRequiredFilled ,
224+ allValid ,
225+ anyUploading ,
226+ fields ,
227+ uploads ,
228+ values ,
229+ onFileUpload ,
230+ onSubmit ,
231+ handleChange
232+ ] ) ;
175233
176234 return (
177235 < div className = 'flex flex-col gap-3' >
@@ -193,7 +251,8 @@ export default function CredentialForm({
193251 multiple = { field . multiple ?? false }
194252 disabled = { disabled }
195253 state = { uploads [ field . key ] }
196- onPick = { ( files ) => runUpload ( field . key , files ) }
254+ onPick = { ( files ) => addSelectedFiles ( field . key , files ) }
255+ onRemoveSelected = { ( index ) => removeSelectedFile ( field . key , index ) }
197256 />
198257 </ div >
199258 ) ;
@@ -254,8 +313,9 @@ export default function CredentialForm({
254313 disabled = { disabled || ! allRequiredFilled || ! allValid || anyUploading }
255314 size = 'sm'
256315 >
257- { buttonLabel }
258- < ArrowRight className = 'ml-1 h-3 w-3' />
316+ { anyUploading ? < Loader2 className = 'size-3 animate-spin' /> : null }
317+ { anyUploading ? "Uploading…" : buttonLabel }
318+ { ! anyUploading && < ArrowRight className = 'size-3' /> }
259319 </ Button >
260320 </ div >
261321 </ div >
@@ -271,6 +331,7 @@ interface FileUploadFieldProps {
271331 disabled ?: boolean ;
272332 state ?: FileUploadState ;
273333 onPick : ( files : File [ ] ) => void ;
334+ onRemoveSelected : ( index : number ) => void ;
274335}
275336
276337function FileUploadField ( {
@@ -281,7 +342,8 @@ function FileUploadField({
281342 multiple,
282343 disabled,
283344 state,
284- onPick
345+ onPick,
346+ onRemoveSelected
285347} : FileUploadFieldProps ) {
286348 const inputRef = useRef < HTMLInputElement | null > ( null ) ;
287349 const [ isDragging , setIsDragging ] = useState ( false ) ;
@@ -330,7 +392,10 @@ function FileUploadField({
330392 [ handleFiles ]
331393 ) ;
332394
333- const hasUploads = ( state ?. uploadedPaths . length ?? 0 ) > 0 ;
395+ const selectedFiles = state ?. selected ?? [ ] ;
396+ const uploadedPaths = state ?. uploadedPaths ?? [ ] ;
397+ const hasSelected = selectedFiles . length > 0 ;
398+ const hasUploaded = uploadedPaths . length > 0 ;
334399 const isUploading = state ?. uploading ?? false ;
335400
336401 return (
@@ -376,14 +441,36 @@ function FileUploadField({
376441 />
377442 { helperText && < p className = 'text-muted-foreground text-xs' > { helperText } </ p > }
378443 { state ?. error && < p className = 'text-destructive text-xs' > { state . error } </ p > }
379- { hasUploads && state && (
444+ { ( hasSelected || hasUploaded ) && (
380445 < ul className = 'flex flex-col gap-1' >
381- { state . uploadedPaths . map ( ( path ) => (
446+ { selectedFiles . map ( ( file , index ) => (
382447 < li
383- key = { path }
448+ // addSelectedFiles dedupes by name+size, so the pair is unique
449+ // within `selected` and stable across sibling removals.
450+ key = { `${ file . name } -${ file . size } ` }
384451 className = 'flex items-center gap-2 rounded border border-border bg-muted/40 px-2 py-1 text-xs'
385452 >
386453 < FileIcon className = 'h-3 w-3 text-muted-foreground' />
454+ < span className = 'flex-1 truncate font-mono' > { file . name } </ span >
455+ < Button
456+ type = 'button'
457+ variant = 'ghost'
458+ size = 'icon'
459+ className = 'size-5 text-muted-foreground hover:text-destructive'
460+ onClick = { ( ) => onRemoveSelected ( index ) }
461+ disabled = { disabled || isUploading }
462+ aria-label = { `Remove ${ file . name } ` }
463+ >
464+ < Trash2 className = 'size-3' />
465+ </ Button >
466+ </ li >
467+ ) ) }
468+ { uploadedPaths . map ( ( path ) => (
469+ < li
470+ key = { path }
471+ className = 'flex items-center gap-2 rounded border border-border bg-muted/40 px-2 py-1 text-xs'
472+ >
473+ < CheckCircle2 className = 'size-3 text-success' />
387474 < span className = 'flex-1 truncate font-mono' > { path } </ span >
388475 </ li >
389476 ) ) }
0 commit comments