11import { type Dispatch , type ReactElement , type SetStateAction , useEffect , useMemo , useState } from 'react' ;
22
33import { OPERATION_RELATIONS , RELATION_COLOR , RELATION_LABEL } from '../constants' ;
4+ import {
5+ applyFilterDraft ,
6+ buildFilterDraft ,
7+ hasPendingFilterChanges ,
8+ hasPendingOperationChanges ,
9+ hasPendingTextFilterChanges ,
10+ } from '../filterDraft' ;
411import type { OperationRelation , ScannedFile } from '../model' ;
512import { cx } from '../utils' ;
613import type { FilterState } from '../viewTypes' ;
@@ -151,6 +158,9 @@ export function LeftPanel({
151158 relatedFiles : false ,
152159 } ) ;
153160 const [ collapsedDirectories , setCollapsedDirectories ] = useState < Set < string > > ( ( ) => new Set ( ) ) ;
161+ const [ draftFilters , setDraftFilters ] = useState ( ( ) => buildFilterDraft ( filters ) ) ;
162+ const [ draftVerticalSpacing , setDraftVerticalSpacing ] = useState ( verticalSpacing ) ;
163+ const [ draftHorizontalSpacing , setDraftHorizontalSpacing ] = useState ( horizontalSpacing ) ;
154164
155165 const filteredRelatedFiles = useMemo ( ( ) => {
156166 const unique = new Map < string , ScannedFile > ( ) ;
@@ -184,8 +194,24 @@ export function LeftPanel({
184194 } ) ;
185195 } , [ directoryPaths ] ) ;
186196
197+ useEffect ( ( ) => {
198+ setDraftFilters ( {
199+ relation : { ...filters . relation } ,
200+ fileQuery : filters . fileQuery ,
201+ search : filters . search ,
202+ } ) ;
203+ } , [ filters . relation , filters . fileQuery , filters . search ] ) ;
204+
205+ useEffect ( ( ) => {
206+ setDraftVerticalSpacing ( verticalSpacing ) ;
207+ } , [ verticalSpacing ] ) ;
208+
209+ useEffect ( ( ) => {
210+ setDraftHorizontalSpacing ( horizontalSpacing ) ;
211+ } , [ horizontalSpacing ] ) ;
212+
187213 const toggleRelation = ( relation : OperationRelation ) => {
188- setFilters ( ( prev ) => ( {
214+ setDraftFilters ( ( prev ) => ( {
189215 ...prev ,
190216 relation : {
191217 ...prev . relation ,
@@ -213,6 +239,29 @@ export function LeftPanel({
213239 } ) ;
214240 } ;
215241
242+ const applyDraftFilters = ( ) => {
243+ if ( ! hasPendingFilterChanges ( filters , draftFilters ) ) {
244+ return ;
245+ }
246+
247+ setFilters ( ( previous ) => applyFilterDraft ( previous , draftFilters ) ) ;
248+ } ;
249+
250+ const hasPendingOperations = hasPendingOperationChanges ( filters , draftFilters ) ;
251+ const hasPendingTextFilters = hasPendingTextFilterChanges ( filters , draftFilters ) ;
252+
253+ const hasPendingLayoutChanges =
254+ draftVerticalSpacing !== verticalSpacing || draftHorizontalSpacing !== horizontalSpacing ;
255+
256+ const applyDraftLayout = ( ) => {
257+ if ( ! hasPendingLayoutChanges ) {
258+ return ;
259+ }
260+
261+ onVerticalSpacingChange ( draftVerticalSpacing ) ;
262+ onHorizontalSpacingChange ( draftHorizontalSpacing ) ;
263+ } ;
264+
216265 const sectionHeader = ( title : string , section : PanelSectionKey , extra ?: string ) => {
217266 const collapsed = collapsedSections [ section ] ;
218267
@@ -369,21 +418,42 @@ export function LeftPanel({
369418 key = { relation }
370419 className = "my-1 flex items-center gap-2 rounded px-1 py-0.5 text-xs hover:bg-zinc-200/70 dark:hover:bg-zinc-800/70"
371420 >
372- < input type = "checkbox" checked = { filters . relation [ relation ] } onChange = { ( ) => toggleRelation ( relation ) } />
421+ < input
422+ type = "checkbox"
423+ checked = { draftFilters . relation [ relation ] }
424+ onChange = { ( ) => toggleRelation ( relation ) }
425+ />
373426 < span
374427 aria-hidden
375428 className = "h-[3px] w-4 shrink-0 rounded-full"
376429 style = { { backgroundColor : RELATION_COLOR [ relation ] } }
377430 />
378431 < span
379432 className = {
380- filters . relation [ relation ] ? 'text-zinc-800 dark:text-zinc-100' : 'text-zinc-500 dark:text-zinc-400'
433+ draftFilters . relation [ relation ]
434+ ? 'text-zinc-800 dark:text-zinc-100'
435+ : 'text-zinc-500 dark:text-zinc-400'
381436 }
382437 >
383438 { RELATION_LABEL [ relation ] }
384439 </ span >
385440 </ label >
386- ) )
441+ ) ) . concat (
442+ < button
443+ key = "apply-operations"
444+ type = "button"
445+ className = { cx (
446+ 'mt-2 w-full rounded-[7px] border px-2 py-[7px] text-[12px] font-medium transition-colors' ,
447+ hasPendingOperations
448+ ? 'border-zinc-500 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 dark:border-zinc-300 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200'
449+ : 'cursor-not-allowed border-zinc-300 bg-zinc-200/80 text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-500' ,
450+ ) }
451+ onClick = { applyDraftFilters }
452+ disabled = { ! hasPendingOperations }
453+ >
454+ Apply Operations
455+ </ button > ,
456+ )
387457 : null }
388458 </ section >
389459
@@ -393,40 +463,65 @@ export function LeftPanel({
393463 < >
394464 < input
395465 className = "mb-2 w-full rounded-[7px] border border-zinc-300 bg-zinc-100 px-2 py-[7px] text-[12px] text-zinc-700 placeholder:text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:placeholder:text-zinc-500"
396- value = { filters . fileQuery }
466+ value = { draftFilters . fileQuery }
397467 placeholder = "Filter files"
398- onChange = { ( event ) => setFilters ( ( prev ) => ( { ...prev , fileQuery : event . target . value } ) ) }
468+ onChange = { ( event ) => setDraftFilters ( ( previous ) => ( { ...previous , fileQuery : event . target . value } ) ) }
469+ onKeyDown = { ( event ) => {
470+ if ( event . key === 'Enter' ) {
471+ applyDraftFilters ( ) ;
472+ }
473+ } }
399474 />
400475 < input
401476 className = "mb-2 w-full rounded-[7px] border border-zinc-300 bg-zinc-100 px-2 py-[7px] text-[12px] text-zinc-700 placeholder:text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:placeholder:text-zinc-500"
402- value = { filters . search }
477+ value = { draftFilters . search }
403478 placeholder = "Search labels"
404- onChange = { ( event ) => setFilters ( ( prev ) => ( { ...prev , search : event . target . value } ) ) }
479+ onChange = { ( event ) => setDraftFilters ( ( previous ) => ( { ...previous , search : event . target . value } ) ) }
480+ onKeyDown = { ( event ) => {
481+ if ( event . key === 'Enter' ) {
482+ applyDraftFilters ( ) ;
483+ }
484+ } }
405485 />
486+ < button
487+ type = "button"
488+ className = { cx (
489+ 'w-full rounded-[7px] border px-2 py-[7px] text-[12px] font-medium transition-colors' ,
490+ hasPendingTextFilters
491+ ? 'border-zinc-500 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 dark:border-zinc-300 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200'
492+ : 'cursor-not-allowed border-zinc-300 bg-zinc-200/80 text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-500' ,
493+ ) }
494+ onClick = { applyDraftFilters }
495+ disabled = { ! hasPendingTextFilters }
496+ >
497+ Apply Filters
498+ </ button >
406499 </ >
407500 ) : null }
408501 </ section >
409502
410503 < section className = "mb-4 shrink-0 border-b border-zinc-300/75 pb-[10px] dark:border-zinc-700/70" >
411504 { sectionHeader ( 'Layout' , 'layout' ) }
412505 { ! collapsedSections . layout ? (
413- < div className = "space-y-2 rounded-[7px] border border-zinc-300 px-2 py-1.5 dark:border-zinc-700 " >
506+ < div className = "space-y-2 px-2 py-1.5" >
414507 < div >
415508 < label
416509 className = "mb-0.5 flex items-center justify-between text-[11px] text-zinc-600 dark:text-zinc-300"
417510 htmlFor = "rqv-vertical-spacing"
418511 >
419512 < span > Vertical Spacing</ span >
420- < span className = "font-medium tabular-nums text-zinc-800 dark:text-zinc-100" > { verticalSpacing } </ span >
513+ < span className = "font-medium tabular-nums text-zinc-800 dark:text-zinc-100" >
514+ { draftVerticalSpacing }
515+ </ span >
421516 </ label >
422517 < input
423518 id = "rqv-vertical-spacing"
424519 type = "range"
425520 min = { 0 }
426521 max = { 300 }
427522 step = { 2 }
428- value = { verticalSpacing }
429- onChange = { ( event ) => onVerticalSpacingChange ( Number ( event . target . value ) ) }
523+ value = { draftVerticalSpacing }
524+ onChange = { ( event ) => setDraftVerticalSpacing ( Number ( event . target . value ) ) }
430525 className = "h-1.5 w-full cursor-pointer accent-zinc-700 dark:accent-zinc-300"
431526 />
432527 </ div >
@@ -437,19 +532,35 @@ export function LeftPanel({
437532 htmlFor = "rqv-horizontal-spacing"
438533 >
439534 < span > Horizontal Spacing</ span >
440- < span className = "font-medium tabular-nums text-zinc-800 dark:text-zinc-100" > { horizontalSpacing } </ span >
535+ < span className = "font-medium tabular-nums text-zinc-800 dark:text-zinc-100" >
536+ { draftHorizontalSpacing }
537+ </ span >
441538 </ label >
442539 < input
443540 id = "rqv-horizontal-spacing"
444541 type = "range"
445542 min = { 100 }
446543 max = { 3000 }
447544 step = { 25 }
448- value = { horizontalSpacing }
449- onChange = { ( event ) => onHorizontalSpacingChange ( Number ( event . target . value ) ) }
545+ value = { draftHorizontalSpacing }
546+ onChange = { ( event ) => setDraftHorizontalSpacing ( Number ( event . target . value ) ) }
450547 className = "h-1.5 w-full cursor-pointer accent-zinc-700 dark:accent-zinc-300"
451548 />
452549 </ div >
550+
551+ < button
552+ type = "button"
553+ className = { cx (
554+ 'w-full rounded-[7px] border px-2 py-[7px] text-[12px] font-medium transition-colors' ,
555+ hasPendingLayoutChanges
556+ ? 'border-zinc-500 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 dark:border-zinc-300 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200'
557+ : 'cursor-not-allowed border-zinc-300 bg-zinc-200/80 text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-500' ,
558+ ) }
559+ onClick = { applyDraftLayout }
560+ disabled = { ! hasPendingLayoutChanges }
561+ >
562+ Apply Layout
563+ </ button >
453564 </ div >
454565 ) : null }
455566 </ section >
0 commit comments