@@ -4,7 +4,20 @@ import { listen } from "@tauri-apps/api/event";
44import { ipc } from "../lib/ipc" ;
55import type { RepoInstallResult , RepoScript , ScriptEntry } from "../types/schema" ;
66import { ScriptIcon } from "../lib/script-icon" ;
7- import { filterCatalog , formatBytes , groupCatalog , hasUpdate , installStateOf } from "../lib/store-utils" ;
7+ import {
8+ ALL_CATEGORIES ,
9+ catalogCategories ,
10+ filterByCategory ,
11+ filterByState ,
12+ filterCatalog ,
13+ formatBytes ,
14+ groupCatalog ,
15+ hasUpdate ,
16+ installStateOf ,
17+ storeCounts ,
18+ STORE_FILTERS ,
19+ } from "../lib/store-utils" ;
20+ import type { StoreFilter } from "../lib/store-utils" ;
821import { useToast } from "./Toast" ;
922import { Modal } from "./Modal" ;
1023import { useI18n } from "../lib/i18n" ;
@@ -92,6 +105,8 @@ export function StoreDialog({ scripts, runningScripts, onInstalled, onRemoved, o
92105 const [ error , setError ] = useState < string | null > ( null ) ;
93106 const [ refreshing , setRefreshing ] = useState ( false ) ;
94107 const [ query , setQuery ] = useState ( "" ) ;
108+ const [ filter , setFilter ] = useState < StoreFilter > ( "all" ) ;
109+ const [ category , setCategory ] = useState < string > ( ALL_CATEGORIES ) ;
95110 const [ dest , setDest ] = useState < string > ( loadDest ) ;
96111 const [ installing , setInstalling ] = useState < InstallState | null > ( null ) ;
97112 const [ confirm , setConfirm ] = useState < ConfirmAction | null > ( null ) ;
@@ -253,9 +268,51 @@ export function StoreDialog({ scripts, runningScripts, onInstalled, onRemoved, o
253268 for ( const e of entries ?? [ ] ) m . set ( e . id , e . name ) ;
254269 return m ;
255270 } , [ entries ] ) ;
256- const visible = filterCatalog ( entries ?? [ ] , query ) ;
271+ const categories = useMemo ( ( ) => catalogCategories ( entries ?? [ ] ) , [ entries ] ) ;
272+ // A Refresh can drop the category the user had picked (its last script left
273+ // the repo). Deriving the effective key instead of storing it means the
274+ // selection heals itself rather than pointing the list at an empty section.
275+ const categoryKey = categories . some ( ( c ) => c . key === category ) ? category : ALL_CATEGORIES ;
276+ const inCategory = useMemo (
277+ ( ) => filterByCategory ( entries ?? [ ] , categoryKey ) ,
278+ [ entries , categoryKey ] ,
279+ ) ;
280+
281+ // The pill badges count within the chosen category, but ignore the search:
282+ // the category is the scope the user set and expects the counts to follow,
283+ // while a badge moving with every keystroke would only repeat how many rows
284+ // the search found — which the list itself already shows.
285+ const counts = useMemo ( ( ) => storeCounts ( inCategory , scripts ) , [ inCategory , scripts ] ) ;
286+ const visible = filterCatalog ( filterByState ( inCategory , scripts , filter ) , query ) ;
257287 const sections = useMemo ( ( ) => groupCatalog ( visible ) , [ visible ] ) ;
258288
289+ const filterLabels : Record < StoreFilter , string > = {
290+ all : t ( "All" ) ,
291+ installed : t ( "Installed" ) ,
292+ updates : t ( "Updates" ) ,
293+ } ;
294+ const filterTitles : Record < StoreFilter , string > = {
295+ all : t ( "All scripts, installed or not" ) ,
296+ installed : t ( "Scripts you have already imported" ) ,
297+ updates : t ( "Installed scripts the repo carries a newer version of" ) ,
298+ } ;
299+
300+ // Why the list is empty, in the words of whatever emptied it: an empty repo,
301+ // a search that found nothing, or a filter with nothing in it. "Nothing
302+ // matches" under an empty search box would be a riddle.
303+ const scoped = categoryKey !== ALL_CATEGORIES ;
304+ const emptyMessage = ( entries ?. length ?? 0 ) === 0
305+ ? t ( "No scripts found in the repo." )
306+ : query . trim ( )
307+ ? t ( "Nothing matches \"{q}\"." , { q : query } )
308+ : filter === "installed"
309+ ? scoped
310+ ? t ( "Nothing from this category is installed yet." )
311+ : t ( "No store scripts are installed yet." )
312+ : scoped
313+ ? t ( "Everything you installed from this category is up to date." )
314+ : t ( "Everything you installed from the store is up to date." ) ;
315+
259316 const confirmTitle = confirm
260317 ? confirm . kind === "update"
261318 ? t ( "Update {name}?" , { name : confirm . entry . name } )
@@ -315,7 +372,7 @@ export function StoreDialog({ scripts, runningScripts, onInstalled, onRemoved, o
315372 </ button >
316373 </ div >
317374
318- < div class = "border-b border-line px-5 py-2.5" >
375+ < div class = "space-y-2 border-b border-line px-5 py-2.5" >
319376 < div class = "relative" >
320377 < SearchIcon
321378 size = { 13 }
@@ -339,6 +396,67 @@ export function StoreDialog({ scripts, runningScripts, onInstalled, onRemoved, o
339396 </ button >
340397 ) }
341398 </ div >
399+
400+ { /* Install state on the left, category scope on the right — two axes
401+ that combine, both narrowing the one list below. Toggle buttons
402+ rather than a `tablist`: they filter a list instead of swapping
403+ panels, and each stays reachable by Tab without arrow-key handling. */ }
404+ < div class = "flex items-center gap-1" >
405+ < div class = "flex items-center gap-1" role = "group" aria-label = { t ( "Filter by install state" ) } >
406+ { STORE_FILTERS . map ( ( f ) => {
407+ const active = filter === f ;
408+ // An idle Updates pill with something in it carries the accent,
409+ // the same colour as the dot on "+ Store" that sent the user here.
410+ const pending = f === "updates" && counts . updates > 0 && ! active ;
411+ return (
412+ < button
413+ key = { f }
414+ type = "button"
415+ aria-pressed = { active }
416+ title = { filterTitles [ f ] }
417+ class = { `pill ${
418+ active
419+ ? "bg-accent/15 text-accent"
420+ : pending
421+ ? "text-accent hover:bg-accent/10"
422+ : "text-subtle hover:text-fg"
423+ } `}
424+ onClick = { ( ) => setFilter ( f ) }
425+ >
426+ { filterLabels [ f ] }
427+ { entries !== null && (
428+ < span class = { `tabular-nums ${ active || pending ? "" : "text-subtle/70" } ` } >
429+ { counts [ f ] }
430+ </ span >
431+ ) }
432+ </ button >
433+ ) ;
434+ } ) }
435+ </ div >
436+
437+ { /* Category scope. A `select` rather than a second row of pills: six
438+ categories fit either way, but the list comes from the repo and
439+ grows without an app release — a dozen pills would wrap, while
440+ this stays one line. Counts sit in the labels, the way the log
441+ view's stream filter carries them. */ }
442+ { categories . length > 1 && (
443+ < select
444+ class = "form-input ml-auto w-auto py-0.5 pl-2 pr-7 text-2xs"
445+ value = { categoryKey }
446+ aria-label = { t ( "Filter by category" ) }
447+ onChange = { ( e ) => setCategory ( e . currentTarget . value ) }
448+ >
449+ < option value = { ALL_CATEGORIES } >
450+ { t ( "All categories" ) } ({ entries ?. length ?? 0 } )
451+ </ option >
452+ { categories . map ( ( c ) => (
453+ < option key = { c . key } value = { c . key } >
454+ { c . label } ({ c . count } )
455+ </ option >
456+ ) ) }
457+ </ select >
458+ ) }
459+ </ div >
342460 </ div >
343461
344462 < div class = "flex-1 space-y-1.5 overflow-y-auto px-4 py-3" >
@@ -363,9 +481,7 @@ export function StoreDialog({ scripts, runningScripts, onInstalled, onRemoved, o
363481
364482 { ! error && entries !== null && visible . length === 0 && (
365483 < p class = "px-1 py-6 text-center text-2xs text-subtle" >
366- { entries . length === 0
367- ? t ( "No scripts found in the repo." )
368- : t ( "Nothing matches \"{q}\"." , { q : query } ) }
484+ { emptyMessage }
369485 </ p >
370486 ) }
371487
0 commit comments