add help mode, tab to sort
This commit is contained in:
parent
e70afe6c05
commit
1d633fe199
|
|
@ -34,6 +34,9 @@ async function bootstrap() {
|
|||
// Initialize default state
|
||||
const state: AppState = {
|
||||
screen: 'setup',
|
||||
previousScreen: 'list',
|
||||
focusedPane: 'list',
|
||||
selectedSettingIndex: 0,
|
||||
config: {
|
||||
url: '',
|
||||
token: null,
|
||||
|
|
|
|||
256
src/tui.ts
256
src/tui.ts
|
|
@ -406,6 +406,18 @@ export class TuiEngine {
|
|||
this.handleLabelsListKeypress(str, key);
|
||||
} else if (this.state.screen === 'create-label' || this.state.screen === 'edit-label') {
|
||||
this.handleLabelFormKeypress(str, key);
|
||||
} else if (this.state.screen === 'help') {
|
||||
this.handleHelpKeypress(str, key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Key handling for Help screen
|
||||
*/
|
||||
private handleHelpKeypress(str: string, key: any) {
|
||||
if ((key && (key.name === 'escape' || key.name === 'return')) || str === 'q' || str === 'Q' || str === '?') {
|
||||
this.state.screen = this.state.previousScreen || 'list';
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -656,6 +668,77 @@ export class TuiEngine {
|
|||
private handleListKeypress(str: string, key: any) {
|
||||
if (this.state.loading) return;
|
||||
|
||||
if (key && key.name === 'tab') {
|
||||
this.state.focusedPane = this.state.focusedPane === 'settings' ? 'list' : 'settings';
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.focusedPane === 'settings') {
|
||||
if (key && key.name === 'left') {
|
||||
this.state.selectedSettingIndex = (this.state.selectedSettingIndex - 1 + 4) % 4;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
if (key && key.name === 'right') {
|
||||
this.state.selectedSettingIndex = (this.state.selectedSettingIndex + 1) % 4;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
if (key && key.name === 'up') {
|
||||
return;
|
||||
}
|
||||
if (key && key.name === 'down') {
|
||||
return;
|
||||
}
|
||||
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
|
||||
const selIdx = this.state.selectedSettingIndex;
|
||||
if (selIdx === 0) {
|
||||
// Search
|
||||
this.activeSearchInput = true;
|
||||
this.searchInputBuffer = this.state.searchQuery;
|
||||
this.render();
|
||||
} else if (selIdx === 1) {
|
||||
// State
|
||||
const states: Array<'open' | 'closed' | 'all'> = ['open', 'closed', 'all'];
|
||||
const currentIdx = states.indexOf(this.state.stateFilter);
|
||||
this.state.stateFilter = states[(currentIdx + 1) % states.length];
|
||||
this.state.currentPage = 1;
|
||||
this.state.selectedIssueIndex = 0;
|
||||
this.loadIssues();
|
||||
} else if (selIdx === 2) {
|
||||
// Type
|
||||
const types: Array<'issues' | 'pulls' | 'all'> = ['issues', 'pulls', 'all'];
|
||||
const currentIdx = types.indexOf(this.state.typeFilter);
|
||||
this.state.typeFilter = types[(currentIdx + 1) % types.length];
|
||||
this.state.currentPage = 1;
|
||||
this.state.selectedIssueIndex = 0;
|
||||
this.loadIssues();
|
||||
} else if (selIdx === 3) {
|
||||
// Sort
|
||||
const fields: Array<'created' | 'updated' | 'comments' | 'assignees'> = ['created', 'updated', 'comments', 'assignees'];
|
||||
const currentSortIdx = fields.indexOf(this.state.sortField);
|
||||
if (this.state.sortOrder === 'desc') {
|
||||
this.state.sortOrder = 'asc';
|
||||
} else {
|
||||
this.state.sortOrder = 'desc';
|
||||
this.state.sortField = fields[(currentSortIdx + 1) % fields.length];
|
||||
}
|
||||
this.state.currentPage = 1;
|
||||
this.state.selectedIssueIndex = 0;
|
||||
this.loadIssues();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (str === '?') {
|
||||
this.state.previousScreen = 'list';
|
||||
this.state.screen = 'help';
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((key && key.name === 'escape') || str === '\u001b') {
|
||||
this.stop();
|
||||
return;
|
||||
|
|
@ -888,6 +971,13 @@ export class TuiEngine {
|
|||
return;
|
||||
}
|
||||
|
||||
if (str === '?') {
|
||||
this.state.previousScreen = 'details';
|
||||
this.state.screen = 'help';
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((key && (key.name === 'escape' || key.name === 'backspace')) || str === '\u001b' || str === 'q' || str === 'Q') {
|
||||
this.state.screen = 'list';
|
||||
this.state.selectedIssue = null;
|
||||
|
|
@ -936,6 +1026,8 @@ export class TuiEngine {
|
|||
this.renderLabelsList(cols, rows);
|
||||
} else if (this.state.screen === 'create-label' || this.state.screen === 'edit-label') {
|
||||
this.renderLabelForm(cols, rows);
|
||||
} else if (this.state.screen === 'help') {
|
||||
this.renderHelpScreen(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -982,23 +1074,37 @@ export class TuiEngine {
|
|||
const renderField = (label: string, value: string, active: boolean, secret: boolean = false) => {
|
||||
const fieldWidth = width - 8;
|
||||
const displayVal = secret ? '*'.repeat(value.length) : value;
|
||||
const cursorStr = active ? chalk.inverse(' ') : '';
|
||||
const valStrLength = displayVal.length + (active ? 1 : 0);
|
||||
const paddingInsideBracket = Math.max(0, fieldWidth - 19 - valStrLength);
|
||||
|
||||
const content = label.padEnd(16) + ': [ ' +
|
||||
(active ? chalk.bold.cyan(displayVal) + cursorStr : chalk.white(displayVal)).padEnd(active ? displayVal.length + cursorStr.length + (fieldWidth - 19 - displayVal.length) : fieldWidth - 19) +
|
||||
' ]';
|
||||
let coloredValStr = displayVal;
|
||||
if (active) {
|
||||
coloredValStr = chalk.bold.cyan(displayVal) + chalk.inverse(' ') + ' '.repeat(paddingInsideBracket);
|
||||
} else {
|
||||
coloredValStr = chalk.white(displayVal) + ' '.repeat(paddingInsideBracket);
|
||||
}
|
||||
|
||||
const plainContent = label.padEnd(16) + ': [ ' + displayVal + (active ? ' ' : '') + ' '.repeat(paddingInsideBracket) + ' ]';
|
||||
const coloredContent = (active ? chalk.bold.white(label.padEnd(16) + ': [ ') : chalk.gray(label.padEnd(16) + ': [ ')) +
|
||||
coloredValStr +
|
||||
(active ? chalk.bold.white(' ]') : chalk.gray(' ]'));
|
||||
|
||||
const coloredContent = active ? chalk.bold.white(content) : chalk.gray(content);
|
||||
const activeMarker = active ? chalk.cyan('▶ ') : ' ';
|
||||
return border + ' ' + activeMarker + coloredContent.padEnd(width - 6) + ' ' + border;
|
||||
const remainingPadding = Math.max(0, width - 6 - plainContent.length);
|
||||
|
||||
return border + ' ' + activeMarker + coloredContent + ' '.repeat(remainingPadding) + ' ' + border;
|
||||
};
|
||||
|
||||
const renderCheckbox = (label: string, checked: boolean, active: boolean) => {
|
||||
const activeMarker = active ? chalk.cyan('▶ ') : ' ';
|
||||
const box = checked ? '[X]' : '[ ]';
|
||||
const content = label.padEnd(16) + ': ' + (active ? chalk.bold.cyan(box) : chalk.white(box));
|
||||
const coloredContent = active ? chalk.bold.white(content) : chalk.gray(content);
|
||||
return border + ' ' + activeMarker + coloredContent.padEnd(width - 6) + ' ' + border;
|
||||
const plainContent = label.padEnd(16) + ': ' + box;
|
||||
const coloredContent = active
|
||||
? chalk.bold.white(label.padEnd(16) + ': ') + chalk.bold.cyan(box)
|
||||
: chalk.gray(label.padEnd(16) + ': ') + chalk.gray(box);
|
||||
|
||||
const remainingPadding = Math.max(0, width - 6 - plainContent.length);
|
||||
return border + ' ' + activeMarker + coloredContent + ' '.repeat(remainingPadding) + ' ' + border;
|
||||
};
|
||||
|
||||
lines.push(renderField('Gitea URL', form.url, form.activeField === 'url'));
|
||||
|
|
@ -1231,7 +1337,11 @@ export class TuiEngine {
|
|||
' ' + timeStr + borderCh;
|
||||
|
||||
if (isSelected) {
|
||||
rowText = chalk.bgHex('#2E4E7E').white.bold(rowText);
|
||||
if (this.state.focusedPane === 'settings') {
|
||||
rowText = chalk.bgHex('#1C2F4D').white(rowText);
|
||||
} else {
|
||||
rowText = chalk.bgHex('#2E4E7E').white.bold(rowText);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(borderCh + rowText);
|
||||
|
|
@ -1245,22 +1355,49 @@ export class TuiEngine {
|
|||
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
||||
|
||||
// Render Filters line
|
||||
let searchLabel = this.state.searchQuery ? chalk.yellow(`"${this.state.searchQuery}"`) : chalk.gray('none');
|
||||
if (this.activeSearchInput) {
|
||||
searchLabel = chalk.inverse(this.searchInputBuffer + ' ');
|
||||
}
|
||||
const stateFilterLabel = chalk.bold(this.state.stateFilter.toUpperCase());
|
||||
const typeFilterLabel = chalk.bold(this.state.typeFilter.toUpperCase());
|
||||
const sortLabel = chalk.bold(`${this.state.sortField} (${this.state.sortOrder.toUpperCase()})`);
|
||||
const isSettings = this.state.focusedPane === 'settings';
|
||||
const selIdx = this.state.selectedSettingIndex ?? 0;
|
||||
|
||||
const buildSegment = (idx: number, label: string, val: string, formattedVal: string) => {
|
||||
const text = ` ${label}: ${val} `;
|
||||
if (isSettings && selIdx === idx) {
|
||||
return chalk.bgHex('#2E4E7E').white.bold(text);
|
||||
}
|
||||
return chalk.gray(` ${label}: `) + formattedVal;
|
||||
};
|
||||
|
||||
const searchPlain = this.activeSearchInput ? this.searchInputBuffer : (this.state.searchQuery || 'none');
|
||||
const searchLabel = this.activeSearchInput
|
||||
? chalk.inverse(this.searchInputBuffer + ' ')
|
||||
: (this.state.searchQuery ? chalk.yellow(`"${this.state.searchQuery}"`) : chalk.gray('none'));
|
||||
|
||||
const statePlain = this.state.stateFilter.toUpperCase();
|
||||
const stateLabel = chalk.bold(statePlain);
|
||||
|
||||
const typePlain = this.state.typeFilter.toUpperCase();
|
||||
const typeLabel = chalk.bold(typePlain);
|
||||
|
||||
const sortPlain = `${this.state.sortField} (${this.state.sortOrder.toUpperCase()})`;
|
||||
const sortLabel = chalk.bold(sortPlain);
|
||||
|
||||
const seg0 = buildSegment(0, 'Search', searchPlain, searchLabel);
|
||||
const seg1 = buildSegment(1, 'State', statePlain, stateLabel);
|
||||
const seg2 = buildSegment(2, 'Type', typePlain, typeLabel);
|
||||
const seg3 = buildSegment(3, 'Sort', sortPlain, sortLabel);
|
||||
|
||||
const separator = chalk.bold.hex('#4A90E2')(' ─ ');
|
||||
const filtersText = ` ${seg0}${separator}${seg1}${separator}${seg2}${separator}${seg3}`;
|
||||
|
||||
const filtersText = ` Search: ${searchLabel} ─ State: ${stateFilterLabel} ─ Type: ${typeFilterLabel} ─ Sort: ${sortLabel}`;
|
||||
const filtersPlainLen = stripAnsi(filtersText).length;
|
||||
const padding = Math.max(0, cols - 2 - filtersPlainLen);
|
||||
console.log(borderCh + filtersText + ' '.repeat(padding) + borderCh);
|
||||
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
|
||||
|
||||
// Keyboard controls help line
|
||||
const fullHelpLine = ' [↑/↓] Navigate [Enter] View [C] Create [/] Search [S] Sort [F] State [T] Type [L] Labels [N/P] Page [R] Reload [O] Settings [Esc] Quit';
|
||||
let fullHelpLine = ' [?] Help [↑/↓] Navigate [Enter] View [C] Create [/] Search [S] Sort [F] State [T] Type [L] Labels [N/P] Page [R] Reload [O] Settings [Esc] Quit';
|
||||
if (isSettings) {
|
||||
fullHelpLine = ' [?] Help [Tab] Back to List [←/→] Select Setting [Enter] Select/Toggle [C] Create [R] Reload [Esc] Quit';
|
||||
}
|
||||
const repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `);
|
||||
const repoLen = stripAnsi(repoStr).length;
|
||||
|
||||
|
|
@ -1400,7 +1537,7 @@ export class TuiEngine {
|
|||
}
|
||||
|
||||
const actionKey = issue.state === 'open' ? 'Close' : 'Reopen';
|
||||
const helpLine = chalk.gray(` [Esc/Backspace] Back to List [C] Add Comment [A] Assign [E] Edit [T] Add Time [X] ${actionKey} [R] Reload [O] Settings ${scrollHelp}`);
|
||||
const helpLine = chalk.gray(` [?] Help [Esc/Backspace] Back to List [C] Add Comment [A] Assign [E] Edit [T] Add Time [X] ${actionKey} [R] Reload [O] Settings ${scrollHelp}`);
|
||||
process.stdout.write(helpLine);
|
||||
}
|
||||
|
||||
|
|
@ -2722,4 +2859,83 @@ export class TuiEngine {
|
|||
console.log(chalk.gray('─'.repeat(cols)));
|
||||
console.log(chalk.gray(' [↑/↓/Tab] Navigate [Enter] Save [Esc] Cancel'));
|
||||
}
|
||||
|
||||
private renderHelpScreen(cols: number, rows: number) {
|
||||
const isDetails = this.state.previousScreen === 'details';
|
||||
|
||||
// Command lists:
|
||||
const commands: Array<{ key: string; desc: string }> = isDetails
|
||||
? [
|
||||
{ key: 'Esc / Backspace', desc: 'Back to Issues List' },
|
||||
{ key: 'C', desc: 'Add Comment to Issue' },
|
||||
{ key: 'A', desc: 'Assign User(s)' },
|
||||
{ key: 'E', desc: 'Edit Title & Description' },
|
||||
{ key: 'T', desc: 'Track Time (add time)' },
|
||||
{ key: 'X', desc: 'Close / Reopen Issue' },
|
||||
{ key: 'R', desc: 'Reload Single Issue' },
|
||||
{ key: 'O', desc: 'Global Setup / Connection Settings' },
|
||||
{ key: '↑ / ↓', desc: 'Scroll description & comments' },
|
||||
{ key: 'PgUp / PgDn', desc: 'Scroll faster (10 lines)' },
|
||||
{ key: '?', desc: 'Show / Close Help Menu' }
|
||||
]
|
||||
: [
|
||||
{ key: '↑ / ↓', desc: 'Navigate issues list' },
|
||||
{ key: 'Enter', desc: 'View selected issue details' },
|
||||
{ key: 'C', desc: 'Create new issue' },
|
||||
{ key: '/', desc: 'Focus Search input field' },
|
||||
{ key: 'S', desc: 'Cycle Sort fields and order' },
|
||||
{ key: 'F', desc: 'Cycle State filter (open/closed/all)' },
|
||||
{ key: 'T', desc: 'Cycle Type filter (issues/pulls/all)' },
|
||||
{ key: 'L', desc: 'View and manage Labels list' },
|
||||
{ key: 'N / PageDown', desc: 'Next page of issues' },
|
||||
{ key: 'P / PageUp', desc: 'Previous page of issues' },
|
||||
{ key: 'R', desc: 'Reload issues list' },
|
||||
{ key: 'Tab', desc: 'Toggle focus (Issues List <=> Settings)' },
|
||||
{ key: 'O', desc: 'Global Setup / Connection Settings' },
|
||||
{ key: 'Q / Esc', desc: 'Quit application' },
|
||||
{ key: '?', desc: 'Show / Close Help Menu' }
|
||||
];
|
||||
|
||||
const title = ` KEYBOARD COMMANDS HELP (${isDetails ? 'DETAILS VIEW' : 'LIST VIEW'}) `;
|
||||
const width = Math.min(74, cols - 4);
|
||||
const height = commands.length + 6; // list + borders + title + instructions
|
||||
|
||||
const leftPadStr = ' '.repeat(Math.max(0, Math.floor((cols - width) / 2)));
|
||||
|
||||
// Top border
|
||||
console.log('\n'); // blank line
|
||||
const borderCh = chalk.bold.hex('#4A90E2');
|
||||
|
||||
const titlePadding = '─'.repeat(Math.max(0, Math.floor((width - 2 - title.length) / 2)));
|
||||
const titleLine = borderCh('┌' + titlePadding + title + titlePadding) + borderCh('─'.repeat(Math.max(0, width - 2 - title.length - 2 * titlePadding.length)) + '┐');
|
||||
console.log(leftPadStr + titleLine);
|
||||
|
||||
// Render lines
|
||||
for (const cmd of commands) {
|
||||
const keyStr = chalk.bold.cyan(cmd.key.padStart(22));
|
||||
const descStr = chalk.white(cmd.desc);
|
||||
const row = ` ${keyStr} ─ ${descStr}`;
|
||||
const rowPlainLen = stripAnsi(row).length;
|
||||
const rowPadding = ' '.repeat(Math.max(0, width - 2 - rowPlainLen));
|
||||
console.log(leftPadStr + borderCh('│') + row + rowPadding + borderCh('│'));
|
||||
}
|
||||
|
||||
// Separator line
|
||||
console.log(leftPadStr + borderCh('├' + '─'.repeat(width - 2) + '┤'));
|
||||
|
||||
// Instructions line
|
||||
const instructions = 'Press [Esc], [q], or [?] to close';
|
||||
const instrPadding = ' '.repeat(Math.max(0, Math.floor((width - 2 - instructions.length) / 2)));
|
||||
const instrLine = borderCh('│') + instrPadding + chalk.yellow.bold(instructions) + ' '.repeat(Math.max(0, width - 2 - instructions.length - instrPadding.length)) + borderCh('│');
|
||||
console.log(leftPadStr + instrLine);
|
||||
|
||||
// Bottom border
|
||||
console.log(leftPadStr + borderCh('└' + '─'.repeat(width - 2) + '┘'));
|
||||
|
||||
// Fill remaining rows
|
||||
const filledRows = 1 + 1 + height + 1; // offset + top border + height + newlines
|
||||
for (let i = filledRows; i < rows; i++) {
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export interface LabelForm {
|
|||
activeField: 'name' | 'color' | 'description' | 'exclusive';
|
||||
}
|
||||
|
||||
export type ScreenType = 'launch' | 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen' | 'set-assignees' | 'labels-list' | 'create-label' | 'edit-label';
|
||||
export type ScreenType = 'launch' | 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen' | 'set-assignees' | 'labels-list' | 'create-label' | 'edit-label' | 'help';
|
||||
|
||||
|
||||
export interface RepoItem {
|
||||
|
|
@ -93,8 +93,12 @@ export interface RepoItem {
|
|||
|
||||
export interface AppState {
|
||||
screen: ScreenType;
|
||||
previousScreen?: ScreenType;
|
||||
config: Config;
|
||||
|
||||
focusedPane: 'list' | 'settings';
|
||||
selectedSettingIndex: number;
|
||||
|
||||
// List Screen State
|
||||
issues: Issue[];
|
||||
currentPage: number;
|
||||
|
|
|
|||
Loading…
Reference in New Issue