diff --git a/README.md b/README.md
index 66a6f5fc..33827646 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,18 @@ 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
+
+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.
+
+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/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/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/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;
+ }
}
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.`;