From 533a2b1e555c6fd3f8ef6231572536d4165764ab Mon Sep 17 00:00:00 2001 From: zaldih Date: Sat, 3 Jan 2026 15:09:28 +0100 Subject: [PATCH 1/3] feat(results): add `/` for search among the results --- src/cli/cli.controller.ts | 7 ++ src/cli/ui/components/header/header.ui.ts | 33 ++++++ src/cli/ui/components/results.ui.ts | 125 ++++++++++++++++++---- 3 files changed, 142 insertions(+), 23 deletions(-) diff --git a/src/cli/cli.controller.ts b/src/cli/cli.controller.ts index 37524f78..ef4c28a6 100644 --- a/src/cli/cli.controller.ts +++ b/src/cli/cli.controller.ts @@ -160,6 +160,13 @@ export class CliController { ); this.uiResults.endNpkill$.subscribe(() => this.quit()); this.uiResults.goOptions$.subscribe(() => this.openOptions()); + this.uiResults.search$.subscribe((state) => { + if (state === null) { + this.uiHeader.setSearch(null); + } else { + this.uiHeader.setSearch(state.text, state.isInputActive); + } + }); // Activate the main interactive component this.activeComponent = this.uiResults; diff --git a/src/cli/ui/components/header/header.ui.ts b/src/cli/ui/components/header/header.ui.ts index 6edf8eaa..1d9452ea 100644 --- a/src/cli/ui/components/header/header.ui.ts +++ b/src/cli/ui/components/header/header.ui.ts @@ -14,6 +14,9 @@ import { MENU_BAR_OPTIONS } from './header-ui.constants.js'; export class HeaderUi extends BaseUi { programVersion: string; private activeMenuIndex = MENU_BAR_OPTIONS.DELETE; + private searchMode = false; + private searchText = ''; + private isSearchInputActive = false; readonly menuIndex$ = new BehaviorSubject( MENU_BAR_OPTIONS.DELETE, @@ -27,6 +30,19 @@ export class HeaderUi extends BaseUi { }); } + setSearch(text: string | null, isInputActive = false) { + if (text === null) { + this.searchMode = false; + this.searchText = ''; + this.isSearchInputActive = false; + } else { + this.searchMode = true; + this.searchText = text; + this.isSearchInputActive = isInputActive; + } + this.render(); + } + render(): void { // banner and tutorial this.printAt(BANNER, UI_POSITIONS.INITIAL); @@ -73,6 +89,23 @@ export class HeaderUi extends BaseUi { } private renderMenuBar(): void { + if (this.searchMode) { + let searchText = ` Search: ${this.searchText} `; + if (this.isSearchInputActive) { + searchText = ` Search: ${this.searchText}_ `; + this.printAt(pc.bgBlue(pc.white(searchText)), { + x: 2, + y: UI_POSITIONS.TUTORIAL_TIP.y, + }); + } else { + this.printAt(pc.bgWhite(pc.black(searchText)), { + x: 2, + y: UI_POSITIONS.TUTORIAL_TIP.y, + }); + } + return; + } + const options = Object.values(MENU_BAR); let xStart = 2; for (const option of options) { diff --git a/src/cli/ui/components/results.ui.ts b/src/cli/ui/components/results.ui.ts index 4bf60866..117372e5 100644 --- a/src/cli/ui/components/results.ui.ts +++ b/src/cli/ui/components/results.ui.ts @@ -38,6 +38,14 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { readonly showDetails$ = new Subject(); readonly goOptions$ = new Subject(); readonly endNpkill$ = new Subject(); + readonly search$ = new Subject<{ + text: string; + isInputActive: boolean; + } | null>(); + + private isSearchInputMode = false; + private searchText = ''; + private filteredResults: CliScanFoundFolder[] = []; private readonly config: IConfig = DEFAULT_CONFIG; private readonly KEYS = { @@ -75,13 +83,13 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { } private openFolder(): void { - const folder = this.resultsService.results[this.resultIndex]; + const folder = this.results[this.resultIndex]; const parentPath = resolve(folder.path, '..'); this.openFolder$.next(parentPath); } private showDetails(): void { - const result = this.resultsService.results[this.resultIndex]; + const result = this.results[this.resultIndex]; if (!result) { return; } @@ -89,6 +97,9 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { } private goOptions(): void { + if (this.searchText) { + return; + } this.goOptions$.next(null); } @@ -120,7 +131,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { this.isRangeSelectionMode = true; this.rangeSelectionStart = this.resultIndex; - const folder = this.resultsService.results[this.resultIndex]; + const folder = this.results[this.resultIndex]; if (folder) { if (this.selectedFolders.has(folder.path)) { this.selectedFolders.delete(folder.path); @@ -135,7 +146,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { return; } - const allFolders = this.resultsService.results; + const allFolders = this.results; const totalFolders = allFolders.length; const selectedCount = this.selectedFolders.size; @@ -160,7 +171,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { } private toggleFolderSelection(): void { - const folder = this.resultsService.results[this.resultIndex]; + const folder = this.results[this.resultIndex]; if (!folder) { return; } @@ -184,7 +195,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { const start = Math.min(this.rangeSelectionStart, this.resultIndex); const end = Math.max(this.rangeSelectionStart, this.resultIndex); - const firstFolder = this.resultsService.results[this.rangeSelectionStart]; + const firstFolder = this.results[this.rangeSelectionStart]; if (!firstFolder) { return; } @@ -192,7 +203,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { const shouldSelect = this.selectedFolders.has(firstFolder.path); for (let i = start; i <= end; i++) { - const folder = this.resultsService.results[i]; + const folder = this.results[i]; if (!folder) { continue; } @@ -217,8 +228,74 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { this.selectedFolders.clear(); } - onKeyInput({ name }: IKeyPress): void { - const action: (() => void) | undefined = this.KEYS[name]; + private activateSearchInputMode(): void { + this.isSearchInputMode = true; + this.search$.next({ text: this.searchText, isInputActive: true }); + this.render(); + } + + private handleSearchInput(key: IKeyPress): void { + if (key.name === 'return' || key.name === 'enter') { + this.isSearchInputMode = false; + if (this.searchText.trim() === '') { + this.searchText = ''; + this.search$.next(null); + } else { + this.search$.next({ text: this.searchText, isInputActive: false }); + } + this.render(); + return; + } + + if (key.name === 'backspace') { + this.searchText = this.searchText.slice(0, -1); + } else if (key.name === 'escape') { + this.isSearchInputMode = false; + this.searchText = ''; + this.search$.next(null); + this.render(); + return; + } else if ( + key.sequence && + key.sequence.length === 1 && + !key.ctrl && + !key.meta + ) { + this.searchText += key.sequence; + } else { + return; + } + + this.filterResults(); + this.search$.next({ text: this.searchText, isInputActive: true }); + this.resultIndex = 0; + this.scroll = 0; + this.render(); + } + + private filterResults(): void { + try { + const regex = new RegExp(this.searchText, 'i'); + this.filteredResults = this.resultsService.results.filter((r) => + regex.test(r.path), + ); + } catch { + this.filteredResults = []; + } + } + + onKeyInput(key: IKeyPress): void { + if (this.isSearchInputMode) { + this.handleSearchInput(key); + return; + } + + if (key.sequence === '/') { + this.activateSearchInputMode(); + return; + } + + const action: (() => void) | undefined = this.KEYS[key.name]; if (action === undefined) { return; } @@ -234,6 +311,8 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { return; } + this.clear(); + if (!this.haveResultsAfterCompleted) { this.noResults(); return; @@ -312,7 +391,8 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { private printFolderRow(folder: CliScanFoundFolder, row: number): void { this.clearLine(row); let { path, lastModification, size } = this.getFolderTexts(folder); - const isRowSelected = row === this.getRealCursorPosY(); + const isRowSelected = + row === this.getRealCursorPosY() && !this.isSearchInputMode; lastModification = isRowSelected ? pc.white(lastModification) @@ -454,7 +534,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { } cursorLastResult(): void { - this.moveCursor(this.resultsService.results.length - 1); + this.moveCursor(this.results.length - 1); } fitScroll(): void { @@ -465,8 +545,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { const shouldScrollDown = this.getRow(this.resultIndex) > this.terminal.rows + this.scroll - 2; - const isOnBotton = - this.resultIndex === this.resultsService.results.length - 1; + const isOnBotton = this.resultIndex === this.results.length - 1; let scrollRequired = 0; @@ -492,11 +571,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { scrollFolderResults(scrollAmount: number): void { const virtualFinalScroll = this.scroll + scrollAmount; - this.scroll = this.clamp( - virtualFinalScroll, - 0, - this.resultsService.results.length, - ); + this.scroll = this.clamp(virtualFinalScroll, 0, this.results.length); this.clear(); } @@ -511,7 +586,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { // Lower limit if (this.isCursorInUpperLimit()) { - this.resultIndex = this.resultsService.results.length - 1; + this.resultIndex = this.results.length - 1; } this.fitScroll(); @@ -590,7 +665,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { const SCROLLBAR_ACTIVE = pc.gray('█'); const SCROLLBAR_BG = pc.gray('░'); - const totalResults = this.resultsService.results.length; + const totalResults = this.results.length; const visibleRows = this.getRowsAvailable(); if (totalResults <= visibleRows) { @@ -622,7 +697,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { } private isCursorInUpperLimit(): boolean { - return this.resultIndex >= this.resultsService.results.length; + return this.resultIndex >= this.results.length; } private getRealCursorPosY(): number { @@ -630,7 +705,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { } private getVisibleScrollFolders(): CliScanFoundFolder[] { - return this.resultsService.results.slice( + return this.results.slice( this.scroll, this.getRowsAvailable() + this.scroll, ); @@ -652,7 +727,7 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { } private delete(): void { - const folder = this.resultsService.results[this.resultIndex]; + const folder = this.results[this.resultIndex]; this.delete$.next(folder); } @@ -673,4 +748,8 @@ export class ResultsUi extends HeavyUi implements InteractiveUi { private clamp(num: number, min: number, max: number): number { return Math.min(Math.max(num, min), max); } + + private get results(): CliScanFoundFolder[] { + return this.searchText ? this.filteredResults : this.resultsService.results; + } } From 577693f1ed5d7924bc08f3c48a4713b3a0cf8be7 Mon Sep 17 00:00:00 2001 From: zaldih Date: Sat, 3 Jan 2026 15:11:46 +0100 Subject: [PATCH 2/3] docs: add search feature documentation --- README.md | 8 ++++++++ src/cli/ui/components/help/help.constants.ts | 1 + src/constants/cli.constants.ts | 1 + 3 files changed, 10 insertions(+) diff --git a/README.md b/README.md index 66a6f5fc..efef35f6 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,14 @@ To exit, Q or Ctrl + c if you're brave. **Important!** Some applications installed on the system need their node_modules directory to work and deleting them may break them. NPKILL will highlight them by displaying a :warning: to be careful. +## Search Mode + +Press / to enter search mode. You can type a regex pattern to filter results. + +Press Enter to confirm the search and navigate the filtered results, or Esc to clear and exit. + +To exit from this mode, leave empty. + ## Multi-Select Mode This mode allows you to select and delete multiple folders at once, making it more efficient when cleaning up many directories. diff --git a/src/cli/ui/components/help/help.constants.ts b/src/cli/ui/components/help/help.constants.ts index 89fbe7a4..0711a693 100644 --- a/src/cli/ui/components/help/help.constants.ts +++ b/src/cli/ui/components/help/help.constants.ts @@ -44,6 +44,7 @@ export const HELP_SECTIONS: HelpSection[] = [ pc.cyan(pc.bold('Actions (normal mode)')), ` ${pc.green('SPACE / DEL')} Delete folder.`, ` ${pc.green('o')} Open parent folder.`, + ` ${pc.green('/')} Search (Regex supported).`, ` ${pc.green('t')} Enter ${pc.green('multi-select mode')}.`, '', pc.cyan(pc.bold('Actions (multi-select mode)')), diff --git a/src/constants/cli.constants.ts b/src/constants/cli.constants.ts index 402372c0..fb95b79a 100644 --- a/src/constants/cli.constants.ts +++ b/src/constants/cli.constants.ts @@ -121,6 +121,7 @@ ${getHeader('How to interact')} ${pc.green('PgDown / Ctrl+d / d / l')} Move one page down. ${pc.green('Home, End')} Jump to first / last result. ${pc.green('o')} Open the parent directory. + ${pc.green('/')} Search (Regex supported). ${pc.green('e')} Show errors. ${pc.green('q')} Quit.`; From e4aa7e8488b8c104bbd46c293becf00bf0e71570 Mon Sep 17 00:00:00 2001 From: zaldih Date: Sat, 3 Jan 2026 15:17:51 +0100 Subject: [PATCH 3/3] docs(readme): add more details to search mode --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index efef35f6..33827646 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,10 @@ To exit, Q or Ctrl + c if you're brave. ## Search Mode +Search mode allows you to filter results. This can be particularly useful for limiting the view to a specific route or ensuring that only those results that meet the specified condition are “selected all.” + +For example, you can use this expression to limit the results to those that are in the `work` directory and that include `data` somewhere in the path: `/work/.*/data`. + Press / to enter search mode. You can type a regex pattern to filter results. Press Enter to confirm the search and navigate the filtered results, or Esc to clear and exit.