fjtui/src/tui.ts

861 lines
29 KiB
TypeScript
Raw Normal View History

2026-06-01 00:59:22 +00:00
import readline from 'readline';
import chalk from 'chalk';
import { AppState, Issue, Comment } from './types.js';
import { fetchIssues, fetchIssueComments, validateConnection, normalizeUrl } from './api.js';
// Setup readline for stdin keypress events
readline.emitKeypressEvents(process.stdin);
// Spinner chars for loading animations
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let spinnerIndex = 0;
let spinnerInterval: NodeJS.Timeout | null = null;
/**
* Word wraps text into lines of a maximum width.
*/
export function wordWrap(text: string, maxWidth: number): string[] {
if (!text) return [];
const lines: string[] = [];
const rawLines = text.split('\n');
for (const rawLine of rawLines) {
if (rawLine.trim() === '') {
lines.push('');
continue;
}
let currentLine = '';
const words = rawLine.split(/\s+/);
for (const word of words) {
if (!word) continue;
if (currentLine.length + word.length + 1 <= maxWidth) {
currentLine += (currentLine ? ' ' : '') + word;
} else {
if (currentLine) {
lines.push(currentLine);
}
// If a single word is longer than maxWidth, force break it
let tempWord = word;
while (tempWord.length > maxWidth) {
lines.push(tempWord.substring(0, maxWidth));
tempWord = tempWord.substring(maxWidth);
}
currentLine = tempWord;
}
}
if (currentLine) {
lines.push(currentLine);
}
}
return lines;
}
/**
* Formats an ISO date string into YYYY-MM-DD HH:MM.
*/
export function formatDate(dateStr: string): string {
try {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return 'n/a';
const yr = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hr = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${yr}-${mo}-${day} ${hr}:${min}`;
} catch {
return 'n/a';
}
}
/**
* Helper to truncate strings to a specific length.
*/
function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.substring(0, length - 3) + '...';
}
/**
* TUI State Controller and Render Engine
*/
export class TuiEngine {
private state: AppState;
private onQuit: () => void = () => {};
private activeSearchInput: boolean = false;
private searchInputBuffer: string = '';
constructor(initialState: AppState) {
this.state = initialState;
// Set up resize handler
process.stdout.on('resize', () => {
this.render();
});
}
/**
* Starts the TUI engine and registers keypress listeners.
*/
public start(onQuit: () => void) {
this.onQuit = onQuit;
// Put terminal in raw mode
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.on('keypress', this.handleKeypress.bind(this));
// Hide standard cursor
process.stdout.write('\x1B[?25l');
// Bootstrap if config exists
if (this.state.config.url && this.state.config.owner && this.state.config.repo) {
this.state.screen = 'list';
this.loadIssues();
} else {
this.render();
}
}
/**
* Stops the TUI engine, restores terminal state.
*/
public stop() {
this.stopSpinner();
// Show standard cursor
process.stdout.write('\x1B[?25h');
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
this.onQuit();
}
private startSpinner() {
this.stopSpinner();
spinnerInterval = setInterval(() => {
spinnerIndex = (spinnerIndex + 1) % SPINNER_FRAMES.length;
this.render();
}, 80);
}
private stopSpinner() {
if (spinnerInterval) {
clearInterval(spinnerInterval);
spinnerInterval = null;
}
}
/**
* Fetches issues from the API client based on current state parameters.
*/
private async loadIssues() {
this.state.loading = true;
this.state.error = null;
this.startSpinner();
try {
const { issues, totalCount } = await fetchIssues(this.state.config, {
page: this.state.currentPage,
limit: this.state.issuesPerPage,
state: this.state.stateFilter,
type: this.state.typeFilter,
q: this.state.searchQuery,
sortField: this.state.sortField,
sortOrder: this.state.sortOrder,
});
this.state.issues = issues;
this.state.totalIssuesCount = totalCount;
// Keep cursor bounds-checked
if (this.state.selectedIssueIndex >= issues.length) {
this.state.selectedIssueIndex = Math.max(0, issues.length - 1);
}
} catch (err: any) {
this.state.error = err.message;
this.state.issues = [];
this.state.totalIssuesCount = 0;
} finally {
this.state.loading = false;
this.stopSpinner();
this.render();
}
}
/**
* Fetches comments for the selected issue.
*/
private async loadComments(issue: Issue) {
this.state.commentsLoading = true;
this.state.selectedIssueComments = [];
this.render();
try {
const comments = await fetchIssueComments(this.state.config, issue.number);
this.state.selectedIssueComments = comments;
} catch (err: any) {
// Gracefully show comment loading error
this.state.selectedIssueComments = [{
id: -1,
user: { id: 0, login: 'system', full_name: 'System Error' },
body: `Failed to load comments: ${err.message}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}];
} finally {
this.state.commentsLoading = false;
this.render();
}
}
/**
* Keyboard input routers
*/
private async handleKeypress(str: string, key: any) {
// Standard Ctrl+C quit
if (key && key.ctrl && key.name === 'c') {
this.stop();
return;
}
if (this.state.screen === 'setup') {
this.handleSetupKeypress(str, key);
} else if (this.state.screen === 'list') {
if (this.activeSearchInput) {
this.handleSearchKeypress(str, key);
} else {
this.handleListKeypress(str, key);
}
} else if (this.state.screen === 'details') {
this.handleDetailsKeypress(str, key);
}
}
/**
* Key handling for Setup form
*/
private async handleSetupKeypress(str: string, key: any) {
const form = this.state.setupForm;
const fields: Array<'url' | 'repo' | 'token'> = ['url', 'repo', 'token'];
const currentIdx = fields.indexOf(form.activeField);
if (key && key.name === 'down') {
form.activeField = fields[(currentIdx + 1) % fields.length];
this.render();
return;
}
if (key && key.name === 'up') {
form.activeField = fields[(currentIdx - 1 + fields.length) % fields.length];
this.render();
return;
}
if (key && key.name === 'tab') {
form.activeField = fields[(currentIdx + 1) % fields.length];
this.render();
return;
}
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
// Submit form
if (!form.url.trim()) {
this.state.error = 'Instance URL is required.';
this.render();
return;
}
if (!form.repo.trim()) {
this.state.error = 'Repository path (owner/repo) is required.';
this.render();
return;
}
const repoParts = form.repo.split('/');
if (repoParts.length !== 2 || !repoParts[0].trim() || !repoParts[1].trim()) {
this.state.error = 'Repository must be in "owner/repo" format.';
this.render();
return;
}
this.state.loading = true;
this.state.error = null;
this.render();
const normalizedUrl = normalizeUrl(form.url);
const testConfig = {
url: normalizedUrl,
token: form.token.trim() || null,
owner: repoParts[0].trim(),
repo: repoParts[1].trim(),
};
try {
await validateConnection(testConfig);
// Save config
this.state.config = testConfig;
this.state.screen = 'list';
this.state.currentPage = 1;
this.state.selectedIssueIndex = 0;
this.loadIssues();
} catch (err: any) {
this.state.error = err.message;
this.state.loading = false;
this.render();
}
return;
}
if ((key && key.name === 'escape') || str === '\u001b') {
this.stop();
return;
}
if (key && key.name === 'backspace') {
if (form.activeField === 'url') form.url = form.url.slice(0, -1);
if (form.activeField === 'repo') form.repo = form.repo.slice(0, -1);
if (form.activeField === 'token') form.token = form.token.slice(0, -1);
this.render();
return;
}
// Type character
if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) {
if (form.activeField === 'url') form.url += str;
if (form.activeField === 'repo') form.repo += str;
if (form.activeField === 'token') form.token += str;
this.render();
}
}
/**
* Key handling for searching overlay
*/
private handleSearchKeypress(str: string, key: any) {
if ((key && key.name === 'escape') || str === '\u001b') {
this.activeSearchInput = false;
this.searchInputBuffer = '';
process.stdout.write('\x1B[?25l'); // Hide cursor
this.render();
return;
}
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
this.activeSearchInput = false;
this.state.searchQuery = this.searchInputBuffer;
this.state.currentPage = 1;
this.state.selectedIssueIndex = 0;
process.stdout.write('\x1B[?25l'); // Hide cursor
this.loadIssues();
return;
}
if (key && key.name === 'backspace') {
this.searchInputBuffer = this.searchInputBuffer.slice(0, -1);
this.render();
return;
}
if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) {
this.searchInputBuffer += str;
this.render();
}
}
/**
* Key handling for Dashboard list
*/
private handleListKeypress(str: string, key: any) {
if (this.state.loading) return;
if ((key && key.name === 'escape') || str === '\u001b') {
// If we entered config via CLI, quit. If we came from setup form, go back to setup!
if (this.state.setupForm.repo) {
this.state.screen = 'setup';
this.state.error = null;
this.render();
} else {
this.stop();
}
return;
}
if (key && key.name === 'up') {
if (this.state.selectedIssueIndex > 0) {
this.state.selectedIssueIndex--;
this.render();
}
return;
}
if (key && key.name === 'down') {
if (this.state.selectedIssueIndex < this.state.issues.length - 1) {
this.state.selectedIssueIndex++;
this.render();
}
return;
}
// Pagination: Right/PageDown -> Next, Left/PageUp -> Prev
if ((key && key.name === 'right') || (key && key.name === 'pagedown') || str === 'n') {
const maxPages = Math.ceil(this.state.totalIssuesCount / this.state.issuesPerPage);
if (this.state.currentPage < maxPages) {
this.state.currentPage++;
this.state.selectedIssueIndex = 0;
this.loadIssues();
}
return;
}
if ((key && key.name === 'left') || (key && key.name === 'pageup') || str === 'p') {
if (this.state.currentPage > 1) {
this.state.currentPage--;
this.state.selectedIssueIndex = 0;
this.loadIssues();
}
return;
}
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
if (this.state.issues.length > 0) {
const issue = this.state.issues[this.state.selectedIssueIndex];
this.state.selectedIssue = issue;
this.state.detailScrollOffset = 0;
this.state.screen = 'details';
this.loadComments(issue);
}
return;
}
// Toggle Filters & Sorting
if (str === '/') {
// Focus Search input
this.activeSearchInput = true;
this.searchInputBuffer = this.state.searchQuery;
this.render();
return;
}
if (str === 's' || str === 'S') {
// Cycle sorts: created -> updated -> comments
const fields: Array<'created' | 'updated' | 'comments'> = ['created', 'updated', 'comments'];
const currentSortIdx = fields.indexOf(this.state.sortField);
// If desc, switch to asc. If asc, switch to next field desc!
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 === 'f' || str === 'F') {
// Cycle states: open -> closed -> all
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();
return;
}
if (str === 't' || str === 'T') {
// Cycle type: issues -> pulls -> all
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();
return;
}
if (str === 'r' || str === 'R') {
// Refresh
this.loadIssues();
return;
}
if (str === 'q' || str === 'Q') {
this.stop();
}
}
/**
* Key handling for detail screen scrolling
*/
private handleDetailsKeypress(str: string, key: any) {
if (key && key.name === 'up') {
if (this.state.detailScrollOffset > 0) {
this.state.detailScrollOffset--;
this.render();
}
return;
}
if (key && key.name === 'down') {
// We will bounds check this dynamically based on content length during render
this.state.detailScrollOffset++;
this.render();
return;
}
if (key && key.name === 'pageup') {
this.state.detailScrollOffset = Math.max(0, this.state.detailScrollOffset - 10);
this.render();
return;
}
if (key && key.name === 'pagedown') {
this.state.detailScrollOffset += 10;
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;
this.state.selectedIssueComments = [];
this.render();
}
}
/**
* Renders the current screen to stdout
*/
public render() {
const cols = process.stdout.columns || 80;
const rows = process.stdout.rows || 24;
// Clear terminal screen and reset cursor position
process.stdout.write('\x1B[2J\x1B[H');
if (this.state.screen === 'setup') {
this.renderSetupScreen(cols, rows);
} else if (this.state.screen === 'list') {
this.renderListScreen(cols, rows);
} else if (this.state.screen === 'details') {
this.renderDetailsScreen(cols, rows);
}
}
/**
* Draw Setup Form
*/
private renderSetupScreen(cols: number, rows: number) {
const form = this.state.setupForm;
const width = Math.min(68, cols - 4);
const border = '│';
const lines: string[] = [];
lines.push(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(width - 2) + '┐'));
const title = 'FORGEJO & GITEA TUI ISSUE EXPLORER';
const titlePadding = ' '.repeat(Math.max(0, Math.floor((width - 2 - title.length) / 2)));
const titleLine = border + titlePadding + chalk.bold.white(title) + ' '.repeat(width - 2 - titlePadding.length - title.length) + border;
lines.push(titleLine);
lines.push(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(width - 2) + '┤'));
// Instructions
lines.push(border + ' '.repeat(width - 2) + border);
const desc = 'Please connect to a Forgejo/Gitea server to explore repository issues.';
lines.push(border + ' ' + chalk.gray(desc.padEnd(width - 4)) + ' ' + border);
lines.push(border + ' '.repeat(width - 2) + border);
// Form inputs
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 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) +
' ]';
const coloredContent = active ? chalk.bold.white(content) : chalk.gray(content);
const activeMarker = active ? chalk.cyan('▶ ') : ' ';
return border + ' ' + activeMarker + coloredContent.padEnd(width - 6) + ' ' + border;
};
lines.push(renderField('Gitea URL', form.url, form.activeField === 'url'));
lines.push(border + ' '.repeat(width - 2) + border);
lines.push(renderField('Repository', form.repo, form.activeField === 'repo'));
lines.push(border + ' ' + chalk.gray('Format: owner/repo (e.g. gitea/tea)').padEnd(width - 5) + border);
lines.push(border + ' '.repeat(width - 2) + border);
lines.push(renderField('Access Token', form.token, form.activeField === 'token', true));
lines.push(border + ' ' + chalk.gray('Optional. Required for private repos.').padEnd(width - 5) + border);
lines.push(border + ' '.repeat(width - 2) + border);
// Error message
if (this.state.error) {
lines.push(chalk.bold.red('├' + '─'.repeat(width - 2) + '┤'));
const errorWrapped = wordWrap(this.state.error, width - 4);
for (const errLine of errorWrapped) {
lines.push(border + ' ' + chalk.bold.red(errLine.padEnd(width - 4)) + ' ' + border);
}
}
lines.push(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(width - 2) + '┘'));
// Loading / Footer indicator
const spinner = this.state.loading ? chalk.cyan(SPINNER_FRAMES[spinnerIndex]) : ' ';
const prompt = this.state.loading ? ' Connecting to instance...' : ' [Tab/Arrows] Navigate [Enter] Connect [Esc] Quit';
// Draw centered on terminal
const vertPadding = Math.max(0, Math.floor((rows - lines.length - 2) / 2));
process.stdout.write('\n'.repeat(vertPadding));
for (const line of lines) {
console.log(' '.repeat(Math.max(0, Math.floor((cols - width) / 2))) + line);
}
const footerText = ' '.repeat(Math.max(0, Math.floor((cols - width) / 2))) + spinner + chalk.gray(prompt);
console.log('\n' + footerText);
// Cursor trick: if typing, place cursor at appropriate spot
if (!this.state.loading) {
// Find current active field offset row
let activeRowOffset = vertPadding + 6; // URL row
if (form.activeField === 'repo') activeRowOffset += 2;
if (form.activeField === 'token') activeRowOffset += 4;
const activeValue = form.activeField === 'token' ? '*'.repeat(form.token.length) : form[form.activeField];
const cursorCol = Math.max(0, Math.floor((cols - width) / 2)) + 23 + activeValue.length;
process.stdout.write(`\x1B[${activeRowOffset + 1};${cursorCol}H\x1B[?25h`); // Show cursor
}
}
/**
* Draw Issues List
*/
private renderListScreen(cols: number, rows: number) {
// Header Bar
const spinnerStr = this.state.loading ? chalk.bold.cyan(SPINNER_FRAMES[spinnerIndex]) + ' ' : '';
const instanceName = normalizeUrl(this.state.config.url).replace(/^https?:\/\//, '');
let header = ` ${spinnerStr}${chalk.bold.hex('#4A90E2')('Forgejo Issue Explorer')}${chalk.bold.white(instanceName)} ─ repo: ${chalk.bold.cyan(`${this.state.config.owner}/${this.state.config.repo}`)}`;
if (this.state.totalIssuesCount > 0) {
const totalPages = Math.ceil(this.state.totalIssuesCount / this.state.issuesPerPage);
header += ` ─ Page ${chalk.bold.white(this.state.currentPage)} of ${chalk.bold.white(totalPages)} (${chalk.yellow(this.state.totalIssuesCount)} matching)`;
} else if (!this.state.loading) {
header += ' ─ (0 issues found)';
}
console.log(header);
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
// Column widths
const idWidth = 6;
const typeWidth = 5;
const stateWidth = 7;
const authorWidth = 14;
const createdWidth = 12;
const commentsWidth = 6;
// Title takes all remaining space
const titleWidth = Math.max(20, cols - idWidth - typeWidth - stateWidth - authorWidth - createdWidth - commentsWidth - 8);
// Render Table Header
const padHeader = (title: string, w: number) => chalk.bold.white(title.padEnd(w));
const borderCh = chalk.bold.hex('#4A90E2')('│');
console.log(
borderCh + ' ' +
padHeader('ID', idWidth) + borderCh + ' ' +
padHeader('Type', typeWidth) + borderCh + ' ' +
padHeader('State', stateWidth) + borderCh + ' ' +
padHeader('Title', titleWidth) + borderCh + ' ' +
padHeader('Author', authorWidth) + borderCh + ' ' +
padHeader('Created', createdWidth) + borderCh + ' ' +
padHeader('Coms', commentsWidth) + borderCh
);
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
// Table Content Height available
const tableHeight = rows - 7; // Header: 1, Box borders: 2, Table headers: 2, Bottom filter line: 1, Keyboard help: 1
if (this.state.issues.length === 0) {
const msg = this.state.loading ? 'Fetching issues from server...' : (this.state.error ? `Error: ${this.state.error}` : 'No issues found.');
const paddingRows = Math.floor((tableHeight - 1) / 2);
for (let i = 0; i < paddingRows; i++) console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
const contentLine = (' '.repeat(Math.max(0, Math.floor((cols - 2 - msg.length) / 2))) + msg).padEnd(cols - 2);
console.log(borderCh + (this.state.error ? chalk.bold.red(contentLine) : chalk.gray(contentLine)) + borderCh);
for (let i = 0; i < tableHeight - paddingRows - 1; i++) console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
} else {
// Loop through issues on current page
for (let i = 0; i < tableHeight; i++) {
if (i < this.state.issues.length) {
const issue = this.state.issues[i];
const isSelected = i === this.state.selectedIssueIndex;
const idStr = `#${issue.number}`.padEnd(idWidth);
const typeStr = (issue.pull_request ? 'PR' : 'Issue').padEnd(typeWidth);
let stateStr = issue.state.toUpperCase().padEnd(stateWidth);
if (issue.state === 'open') {
stateStr = issue.pull_request ? chalk.bold.magenta(stateStr) : chalk.bold.green(stateStr);
} else {
stateStr = chalk.bold.red(stateStr);
}
const titleStr = truncate(issue.title, titleWidth).padEnd(titleWidth);
const authorStr = truncate(issue.user.login, authorWidth).padEnd(authorWidth);
const createdStr = formatDate(issue.created_at).substring(0, 10).padEnd(createdWidth);
const commentsStr = String(issue.comments).padEnd(commentsWidth);
let rowText =
' ' + idStr + borderCh +
' ' + typeStr + borderCh +
' ' + stateStr + borderCh +
' ' + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh +
' ' + authorStr + borderCh +
' ' + createdStr + borderCh +
' ' + commentsStr + borderCh;
if (isSelected) {
rowText = chalk.bgHex('#2E4E7E').white.bold(rowText);
}
console.log(borderCh + rowText);
} else {
// Fill empty space
console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
}
}
}
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 filtersText = ` Search: ${searchLabel} ─ State: ${stateFilterLabel} ─ Type: ${typeFilterLabel} ─ Sort: ${sortLabel}`;
console.log(borderCh + filtersText.padEnd(cols - 2) + borderCh);
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
// Keyboard controls help line
const helpLine = chalk.gray(' [↑/↓] Navigate [Enter] View [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [Esc] Back');
process.stdout.write(helpLine);
// If search active, position terminal cursor in search box
if (this.activeSearchInput) {
const searchCursorCol = 11 + this.searchInputBuffer.length;
process.stdout.write(`\x1B[${rows - 1};${searchCursorCol}H\x1B[?25h`); // Show cursor
}
}
/**
* Draw Issue Detail scrollable view
*/
private renderDetailsScreen(cols: number, rows: number) {
const issue = this.state.selectedIssue;
if (!issue) return;
// Header info line
const isPR = !!issue.pull_request;
const typeTag = isPR ? chalk.bold.magenta('[PR] ') : chalk.bold.green('[Issue] ');
console.log(` ${typeTag}#${issue.number}${chalk.bold.white(truncate(issue.title, cols - 16))}`);
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
// Metadata lines
const borderCh = chalk.bold.hex('#4A90E2')('│');
let stateLabel = issue.state.toUpperCase();
if (issue.state === 'open') {
stateLabel = isPR ? chalk.bold.magenta(stateLabel) : chalk.bold.green(stateLabel);
} else {
stateLabel = chalk.bold.red(stateLabel);
}
const labels = issue.labels.map(l => chalk.bgHex('#' + l.color).black(` ${l.name} `)).join(' ');
console.log(borderCh + ` State: ${stateLabel} Author: ${chalk.cyan(issue.user.login)} Created: ${formatDate(issue.created_at)} Updated: ${formatDate(issue.updated_at)}`.padEnd(cols - 2) + borderCh);
if (labels) {
console.log(borderCh + ` Labels: ${labels}`.padEnd(cols - 2 + labels.length - issue.labels.map(l=>l.name.length + 10).reduce((a,b)=>a+b, 0)) + borderCh); // offset for raw ANSI chars
}
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
// Gather and format all detail text (Body + Comments)
const contentLines: string[] = [];
// 1. Render Body
contentLines.push(chalk.bold.yellow('--- DESCRIPTION ---'));
if (issue.body.trim()) {
const wrappedBody = wordWrap(issue.body, cols - 6);
contentLines.push(...wrappedBody);
} else {
contentLines.push(chalk.italic.gray('No description provided.'));
}
contentLines.push('');
// 2. Render Comments Section
contentLines.push(chalk.bold.yellow(`--- COMMENTS (${issue.comments}) ---`));
if (this.state.commentsLoading) {
contentLines.push(chalk.cyan(' Loading comments...'));
} else if (this.state.selectedIssueComments.length === 0) {
contentLines.push(chalk.italic.gray(' No comments.'));
} else {
for (const comment of this.state.selectedIssueComments) {
contentLines.push(chalk.bold.cyan(`${comment.user.login}`) + chalk.gray(` at ${formatDate(comment.created_at)}`));
const wrappedComment = wordWrap(comment.body, cols - 8);
for (const cLine of wrappedComment) {
contentLines.push(' ' + cLine);
}
contentLines.push(''); // blank line between comments
}
}
// Paginate/Scroll calculations
const displayHeight = rows - 6; // Header: 1, borders: 2, meta: 2, help: 1
const maxScroll = Math.max(0, contentLines.length - displayHeight);
// Bounds check scroll offset
if (this.state.detailScrollOffset > maxScroll) {
this.state.detailScrollOffset = maxScroll;
}
// Print visible window of content
for (let r = 0; r < displayHeight; r++) {
const lineIndex = r + this.state.detailScrollOffset;
if (lineIndex < contentLines.length) {
const line = contentLines[lineIndex];
console.log(borderCh + ' ' + line.padEnd(cols - 4) + ' ' + borderCh);
} else {
console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
}
}
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
// Footer Scroll percentage
let scrollHelp = ' Scroll: [↑/↓] / [PgUp/PgDn]';
if (maxScroll > 0) {
const pct = Math.round((this.state.detailScrollOffset / maxScroll) * 100);
scrollHelp += chalk.yellow(` (${pct}%)`);
}
const helpLine = chalk.gray(` [Esc/Backspace] Back to List ${scrollHelp}`);
process.stdout.write(helpLine);
}
}