diff --git a/src/cli/ui/components/help.ui.ts b/src/cli/ui/components/help.ui.ts deleted file mode 100644 index 79a45eb6..00000000 --- a/src/cli/ui/components/help.ui.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { MARGINS } from '../../../constants/main.constants.js'; -import { BaseUi, InteractiveUi } from '../base.ui.js'; -import { IKeyPress } from '../../interfaces/key-press.interface.js'; -import { Subject } from 'rxjs'; -import colors from 'colors'; - -export class HelpUi extends BaseUi implements InteractiveUi { - resultIndex = 0; - - readonly goToOptions$ = new Subject(); - - private readonly KEYS = { - right: () => this.goToOptions(), - l: () => this.goToOptions(), - }; - - constructor() { - super(); - } - - private goToOptions(): void { - this.clear(); - this.goToOptions$.next(null); - } - - onKeyInput({ name }: IKeyPress): void { - const action: (() => void) | undefined = this.KEYS[name]; - if (action === undefined) { - return; - } - action(); - } - - render(): void { - this.clear(); - // const maxWidth = Math.min(this.terminal.columns, 80); - const startRow = MARGINS.ROW_RESULTS_START; - // let currentRow = startRow; - - this.printAt(colors.bgCyan.black('HELP PAGE WORK!'), { - x: 3, - y: startRow + 3, - }); - } - - clear(): void { - for (let row = MARGINS.ROW_RESULTS_START; row < this.terminal.rows; row++) { - this.clearLine(row); - } - } -} diff --git a/src/cli/ui/components/help-command.ui.ts b/src/cli/ui/components/help/help-command.ui.ts similarity index 89% rename from src/cli/ui/components/help-command.ui.ts rename to src/cli/ui/components/help/help-command.ui.ts index 019e8cbf..a5fe3450 100644 --- a/src/cli/ui/components/help-command.ui.ts +++ b/src/cli/ui/components/help/help-command.ui.ts @@ -3,11 +3,11 @@ import { OPTIONS, HELP_FOOTER, HELP_PROGRESSBAR, -} from '../../../constants/cli.constants.js'; -import { MARGINS, UI_HELP } from '../../../constants/main.constants.js'; -import { INFO_MSGS } from '../../../constants/messages.constants.js'; -import { ConsoleService } from '../../services/console.service.js'; -import { BaseUi } from '../base.ui.js'; +} from '../../../../constants/cli.constants.js'; +import { MARGINS, UI_HELP } from '../../../../constants/main.constants.js'; +import { INFO_MSGS } from '../../../../constants/messages.constants.js'; +import { ConsoleService } from '../../../services/console.service.js'; +import { BaseUi } from '../../base.ui.js'; import colors from 'colors'; export class HelpCommandUi extends BaseUi { diff --git a/src/cli/ui/components/help/help.constants.ts b/src/cli/ui/components/help/help.constants.ts new file mode 100644 index 00000000..fba0b491 --- /dev/null +++ b/src/cli/ui/components/help/help.constants.ts @@ -0,0 +1,278 @@ +/* eslint-disable quotes */ +import colors from 'colors'; + +interface HelpSection { + id: string; + title: string; + icon: string; + content: string[]; +} + +export const HELP_SECTIONS: HelpSection[] = [ + { + id: 'welcome', + title: 'Welcome', + icon: '👋', + content: [ + colors.bold('Welcome to npkill!'), + '', + 'Npkill helps you find and manage "junk" directories', + 'left behind by development tools.', + '', + 'These folders are essential while working on projects,', + 'but over time they pile up, eating tons of space long', + "after you've moved on.", + '', + 'Npkill scans your directories, lists these folders with', + 'their sizes, and shows when you last touched each project,', + 'so you can quickly decide what to keep and what to clean.', + '', + colors.green.bold('Easy and powerful!'), + ], + }, + { + id: 'shortcuts', + title: 'Quick Reference', + icon: 'âŒ¨ī¸', + content: [ + colors.cyan(colors.bold('Navigation')), + ` ${colors.green('↑/↓ or j/k')} Move cursor.`, + ` ${colors.green('←/→ or h/l')} Switch panels.`, + ` ${colors.green('PgUp/PgDown')} Fast scroll.`, + ` ${colors.green('Home/End')} Jump to first/last.`, + '', + colors.cyan(colors.bold('Actions (normal mode)')), + ` ${colors.green('SPACE / DEL')} Delete folder.`, + ` ${colors.green('o')} Open parent folder.`, + ` ${colors.green('t')} Enter ${colors.green('multi-select mode')}.`, + '', + colors.cyan(colors.bold('Actions (multi-select mode)')), + ` ${colors.green('t')} Back to ${colors.green('normal mode')} without delete.`, + ` ${colors.green('v')} Range selection.`, + ` ${colors.green('a')} Select/deselect all.`, + ` ${colors.green('SPACE')} Select/deselect current.`, + ` ${colors.green('ENTER')} Delete selected folders.`, + '', + colors.cyan(colors.bold('Others')), + ` ${colors.green('e')} Show errors.`, + ` ${colors.green('q / ESC')} Quit npkill.`, + ], + }, + { + id: 'header', + title: 'Header Info', + icon: '📊', + content: [ + colors.bold('Understanding the header'), + '', + colors.bold.green('Potential Space'), + 'The total size of all detected directories.', + 'This represents the maximum possible space you could', + 'free if you deleted everything.', + '', + colors.bold.green('Freed Space'), + 'The space actually recovered in this session.', + '', + colors.bold.green('Last Modified (last-mod)'), + 'Shows when the last file in the parent workspace was', + 'modified. This helps identify abandoned projects.', + '', + colors.dim('Note: This checks the entire parent directory,'), + colors.dim('not just the target folder itself.'), + '', + colors.bold.green('Progress Bar'), + 'The progress bar has 3 color-coded parts:', + '', + ` ${colors.green('▀▀▀▀')} Green → Results ready (stats calculated)`, + ` ${colors.white('▀▀▀▀')} White → Directories examined`, + ` ${colors.gray('▀▀▀▀')} Gray → Pending to be analyzed`, + '', + 'Full bar example:', + ` ${colors.green('▀▀▀▀▀▀▀')}${colors.white('▀▀▀▀')}${colors.gray('▀▀▀▀▀▀▀▀▀▀▀')}`, + ], + }, + { + id: 'warnings', + title: 'Warnings', + icon: 'âš ī¸', + content: [ + colors.bold.yellow('Important: Not all results are safe to delete!'), + '', + 'Some applications (VSCode, Discord, Slack, etc.) need', + 'their dependencies to work. If their directory is deleted,', + 'the application will probably break until dependencies', + 'are reinstalled.', + '', + 'Npkill will try to detect this folders and show "âš ī¸"', + 'alongside the result and mark it as "sensitive".', + '', + '', + colors.bold.green('Pro tip'), + 'Use the Info panel (→) to see more information', + 'about why a folder is flagged.', + ], + }, + { + id: 'panels', + title: 'Panels', + icon: '📋', + content: [ + colors.bold('Understanding the interface'), + '', + colors.bold.cyan('Delete Panel (Main)'), + 'Lists all found directories with their sizes.', + 'Navigate with arrow keys or j/k.', + 'Press SPACE to delete a folder.', + 'Press → to see details.', + '', + colors.bold.cyan('Info Panel'), + 'Shows details of the selected result. Plus notes', + 'that might be useful to you.', + '', + colors.bold.cyan('Options Panel'), + 'Configure npkill settings on the fly.', + '', + colors.bold.cyan('Help Panel (You are here!)'), + 'Navigate through help sections.', + 'Use ↑/↓ to change sections.', + 'Scroll content with j/k or arrow keys.', + ], + }, + // { // TODO pending to add .npkillrc support + // id: 'config', + // title: 'Configuration', + // icon: 'âš™ī¸', + // content: [ + // colors.bold('Customizing npkill with .npkillrc'), + // '', + // colors.bold.cyan('What is .npkillrc?'), + // 'A configuration file where you can set your default', + // 'preferences, custom profiles, and target folders.', + // '', + // colors.bold.cyan('Location'), + // '', + // 'Place it in your home directory:', + // colors.dim(' ~/.npkillrc'), + // '', + // 'Or load from a custom location:', + // colors.green(' npkill --config /path/to/config'), + // '', + // colors.bold.cyan('Example Configuration'), + // '', + // colors.dim('{'), + // colors.dim(' "targets": ["node_modules", ".venv", "target"],'), + // colors.dim(' "exclude": [".git", "important-project"],'), + // colors.dim(' "sortBy": "size",'), + // colors.dim(' "backgroundColor": "bgCyan",'), + // colors.dim(' "excludeHiddenDirectories": true,'), + // colors.dim(' "profiles": {'), + // colors.dim(' "mystack": {'), + // colors.dim(' "targets": ["node_modules", "venv", "target"]'), + // colors.dim(' }'), + // colors.dim(' }'), + // colors.dim('}'), + // '', + // colors.bold.cyan('Available Options'), + // `${colors.green('targets')} Target folder names to search`, + // `${colors.green('exclude')} Directories to skip`, + // `${colors.green('sortBy')} Default sort (size/path/last-mod)`, + // `${colors.green('backgroundColor')} Cursor highlight color`, + // `${colors.green('sizeUnit')} Display unit (auto/mb/gb)`, + // `${colors.green('profiles')} Custom profile definitions`, + // ], + // }, + { + id: 'profiles', + title: 'Profiles', + icon: 'đŸ“Ļ', + content: [ + colors.bold('Working with profiles'), + '', + colors.bold.cyan('What are profiles?'), + 'Profiles are presets of target folders for different', + 'programming languages and tools.', + '', + colors.bold.cyan('Usage'), + 'List available profiles:', + colors.green(' npkill -p'), + '', + 'Single profile:', + colors.green(' npkill -p node'), + '', + 'Multiple profiles:', + colors.green(' npkill -p node,python,java'), + '', + // colors.bold.cyan('Custom Profiles'), + // 'Define your own in .npkillrc:', + // '', + // colors.dim('"profiles": {'), + // colors.dim(' "webdev": {'), + // colors.dim(' "targets": ["node_modules", "dist", ".next"]'), + // colors.dim(' }'), + // colors.dim('}'), + // '', + // 'Then use:', + // colors.green(' npkill -p webdev'), + ], + }, + { + id: 'tips', + title: 'Tips & Tricks', + icon: '💡', + content: [ + colors.bold('Get the most out of npkill'), + '', + colors.bold.cyan('1. Use profiles'), + ' Start with profiles for common tools:', + ` ${colors.green('npkill -p node,python,java')}.`, + '', + colors.bold.cyan('2. Sort intelligently'), + ' Sort by size to find the biggest space hogs:', + ` ${colors.green('npkill --sort size')}`, + ' Or by last-mod to find abandoned projects.', + '', + colors.bold.cyan('3. Exclude what you need'), + ' Protect important directories:', + ` ${colors.green('npkill -E ".git,important_project"')}`, + '', + colors.bold.cyan('4. Try dry-run mode'), + ' Test without risk using --dry-run.', + ' Nothing is deleted! (simulated with delay).', + '', + colors.bold.cyan('5. Multi-select workflow'), + ' Press "t" to enter selection mode.', + ' Mark folders with SPACE.', + ' Press ENTER to delete all at once.', + '', + ' Press "v" on first item.', + ' Move to last item and press "v" again.', + ' All items in between are selected!', + '', + colors.bold.cyan('6. Check details first'), + ' Unsure about a result? Press → to see', + ' more details.', + ], + }, + { + id: 'about', + title: 'About', + icon: 'â„šī¸', + content: [ + colors.bold('About npkill'), + '', + 'Npkill, a tool designed to help developers', + 'reclaim disk space by finding and removing', + 'unnecessary dependency folders.', + '', + colors.bold.cyan('Get Involved'), + 'GitHub: github.com/voidcosmos/npkill', + 'Report bugs, request features, contribute!', + '', + colors.bold.cyan('License'), + 'MIT License - Free and open source.', + '', + '', + colors.dim('Made with â¤ī¸'), + ], + }, +]; diff --git a/src/cli/ui/components/help/help.ui.ts b/src/cli/ui/components/help/help.ui.ts new file mode 100644 index 00000000..de03616e --- /dev/null +++ b/src/cli/ui/components/help/help.ui.ts @@ -0,0 +1,239 @@ +import { MARGINS } from '../../../../constants/main.constants.js'; +import { BaseUi, InteractiveUi } from '../../base.ui.js'; +import { IKeyPress } from '../../../interfaces/key-press.interface.js'; +import { Subject } from 'rxjs'; +import colors from 'colors'; +import { HELP_SECTIONS } from './help.constants.js'; + +export class HelpUi extends BaseUi implements InteractiveUi { + resultIndex = 0; + + readonly goToOptions$ = new Subject(); + + private selectedSection = 0; + private scrollOffset = 0; + private readonly INDEX_WIDTH = 23; + private readonly SCROLL_STEP = 2; + + private readonly KEYS = { + up: () => this.previousSection(), + down: () => this.nextSection(), + k: () => this.scrollUp(), + j: () => this.scrollDown(), + u: () => this.scrollPageUp(), + d: () => this.scrollPageDown(), + pageup: () => this.scrollPageUp(), + pagedown: () => this.scrollPageDown(), + home: () => this.scrollToTop(), + end: () => this.scrollToBottom(), + right: () => this.goToOptions(), + l: () => this.goToOptions(), + q: () => this.goToOptions(), + escape: () => this.goToOptions(), + return: () => this.selectSection(), + }; + + constructor() { + super(); + } + + private previousSection(): void { + if (this.selectedSection > 0) { + this.selectedSection--; + this.scrollOffset = 0; + this.render(); + } + } + + private nextSection(): void { + if (this.selectedSection < HELP_SECTIONS.length - 1) { + this.selectedSection++; + this.scrollOffset = 0; + this.render(); + } + } + + private selectSection(): void { + this.scrollOffset = 0; + this.render(); + } + + private scrollUp(): void { + if (this.scrollOffset > 0) { + this.scrollOffset = Math.max(0, this.scrollOffset - this.SCROLL_STEP); + this.render(); + } + } + + private scrollDown(): void { + const currentSection = HELP_SECTIONS[this.selectedSection]; + const contentHeight = this.getContentAreaHeight(); + const maxScroll = Math.max( + 0, + currentSection.content.length - contentHeight, + ); + + if (this.scrollOffset < maxScroll) { + this.scrollOffset = Math.min( + maxScroll, + this.scrollOffset + this.SCROLL_STEP, + ); + this.render(); + } + } + + private scrollPageUp(): void { + const pageSize = this.getContentAreaHeight() - 2; + this.scrollOffset = Math.max(0, this.scrollOffset - pageSize); + this.render(); + } + + private scrollPageDown(): void { + const currentSection = HELP_SECTIONS[this.selectedSection]; + const contentHeight = this.getContentAreaHeight(); + const pageSize = contentHeight - 2; + const maxScroll = Math.max( + 0, + currentSection.content.length - contentHeight, + ); + + this.scrollOffset = Math.min(maxScroll, this.scrollOffset + pageSize); + this.render(); + } + + private scrollToTop(): void { + this.scrollOffset = 0; + this.render(); + } + + private scrollToBottom(): void { + const currentSection = HELP_SECTIONS[this.selectedSection]; + const contentHeight = this.getContentAreaHeight(); + this.scrollOffset = Math.max( + 0, + currentSection.content.length - contentHeight, + ); + this.render(); + } + + private goToOptions(): void { + this.clear(); + this.goToOptions$.next(null); + } + + private getContentAreaHeight(): number { + return this.terminal.rows - MARGINS.ROW_RESULTS_START - 4; + } + + onKeyInput({ name }: IKeyPress): void { + const action: (() => void) | undefined = this.KEYS[name]; + if (action === undefined) { + return; + } + action(); + } + + render(): void { + this.clear(); + const startRow = MARGINS.ROW_RESULTS_START; + const contentAreaHeight = this.getContentAreaHeight(); + + // Header hint + this.printAt( + colors.dim('Use ') + + colors.green('↑/↓') + + colors.dim(' to change section, ') + + colors.green('j/k') + + colors.dim(' to scroll.'), + { x: 2, y: startRow }, + ); + + this.drawIndex(startRow + 2); + this.drawContent(startRow + 2, contentAreaHeight); + } + + private drawIndex(startRow: number): void { + const indexHeight = this.terminal.rows - startRow - 1; + + this.printAt(colors.gray('╭' + '─'.repeat(this.INDEX_WIDTH - 2) + '╮'), { + x: 2, + y: startRow - 1, + }); + + for (let i = 0; i < Math.min(HELP_SECTIONS.length, indexHeight); i++) { + const section = HELP_SECTIONS[i]; + const isSelected = i === this.selectedSection; + + const padding = ' '.repeat( + Math.max(0, this.INDEX_WIDTH - section.title.length - 6), + ); + const line = ` ${section.icon} ${section.title}${padding}`; + + if (isSelected) { + this.printAt( + colors.gray('│') + colors.bgCyan.black(line) + colors.gray('│'), + { x: 2, y: startRow + i }, + ); + } else { + this.printAt(colors.gray('│') + colors.white(line) + colors.gray('│'), { + x: 2, + y: startRow + i, + }); + } + } + + const bottomRow = startRow + Math.min(HELP_SECTIONS.length, indexHeight); + this.printAt(colors.gray('╰' + '─'.repeat(this.INDEX_WIDTH - 2) + '╯'), { + x: 2, + y: bottomRow, + }); + } + + private drawContent(startRow: number, contentHeight: number): void { + const currentSection = HELP_SECTIONS[this.selectedSection]; + const contentStartX = this.INDEX_WIDTH + 2; + const contentWidth = Math.max( + 20, + this.terminal.columns - contentStartX - 4, + ); + + this.printAt(colors.gray('╭' + '─'.repeat(contentWidth) + '╮'), { + x: contentStartX, + y: startRow - 1, + }); + + const visibleContent = currentSection.content.slice( + this.scrollOffset, + this.scrollOffset + contentHeight, + ); + + for (let i = 0; i < contentHeight; i++) { + const line = visibleContent[i] || ''; + + const padding = ' '.repeat( + Math.max(0, contentWidth - this.getStringWidth(line) - 1), + ); + this.printAt(colors.gray('│ ') + line + padding + colors.gray('│'), { + x: contentStartX, + y: startRow + i, + }); + } + + this.printAt(colors.gray('╰' + '─'.repeat(contentWidth) + '╯'), { + x: contentStartX, + y: startRow + contentHeight, + }); + } + + /** Get real width, removing ANSI color codes. */ + private getStringWidth(str: string): number { + // eslint-disable-next-line no-control-regex + return str.replace(/\u001b\[[0-9;]*m/g, '').length; + } + + clear(): void { + for (let row = MARGINS.ROW_RESULTS_START; row < this.terminal.rows; row++) { + this.clearLine(row); + } + } +} diff --git a/src/cli/ui/index.ts b/src/cli/ui/index.ts index 62ac10c0..2190ff36 100644 --- a/src/cli/ui/index.ts +++ b/src/cli/ui/index.ts @@ -1,8 +1,8 @@ export * from './base.ui.js'; export * from './heavy.ui.js'; export * from './components/general.ui.js'; -export * from './components/help.ui.js'; -export * from './components/help-command.ui.js'; +export * from './components/help/help.ui.js'; +export * from './components/help/help-command.ui.js'; export * from './components/logs.ui.js'; export * from './components/warning.ui.js'; export * from './components/results.ui.js'; diff --git a/tests/cli/cli.controller.test.ts b/tests/cli/cli.controller.test.ts index 0742640d..4715082f 100644 --- a/tests/cli/cli.controller.test.ts +++ b/tests/cli/cli.controller.test.ts @@ -45,7 +45,7 @@ jest.unstable_mockModule( jest.unstable_mockModule('../../src/cli/ui/components/general.ui.js', () => ({ GeneralUi: jest.fn(), })); -jest.unstable_mockModule('../../src/cli/ui/components/help.ui.js', () => ({ +jest.unstable_mockModule('../../src/cli/ui/components/help/help.ui.js', () => ({ HelpUi: jest.fn(), })); jest.unstable_mockModule('../../src/cli/ui/components/results.ui.js', () => ({