2026-06-01 00:59:22 +00:00
|
|
|
import readline from 'readline';
|
|
|
|
|
import chalk from 'chalk';
|
|
|
|
|
import { AppState, Issue, Comment } from './types.js';
|
2026-06-02 18:41:33 +00:00
|
|
|
import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue, addIssueTime, changeIssueState, setIssueAssignees } from './api.js';
|
2026-06-01 02:27:57 +00:00
|
|
|
import { saveGlobalConfig } from './config.js';
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
// Setup readline for stdin keypress events
|
|
|
|
|
readline.emitKeypressEvents(process.stdin);
|
|
|
|
|
|
|
|
|
|
// Spinner chars for loading animations
|
|
|
|
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
|
|
|
let spinnerIndex = 0;
|
2026-06-02 00:36:39 +00:00
|
|
|
|
|
|
|
|
const GRAVESTONE_FRAMES = [
|
|
|
|
|
[
|
|
|
|
|
chalk.gray(' '),
|
|
|
|
|
chalk.gray(' '),
|
|
|
|
|
chalk.gray(' '),
|
|
|
|
|
chalk.gray(' '),
|
|
|
|
|
chalk.gray(' ___________ '),
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
chalk.gray(' '),
|
|
|
|
|
chalk.gray(' '),
|
|
|
|
|
chalk.gray(' '),
|
|
|
|
|
chalk.gray(' ___ '),
|
|
|
|
|
chalk.gray(' ___/___\\___ '),
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
chalk.gray(' '),
|
|
|
|
|
chalk.gray(' '),
|
|
|
|
|
chalk.gray(' ___ '),
|
|
|
|
|
chalk.gray(' / \\ '),
|
|
|
|
|
chalk.gray(' __|_____|__ '),
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
chalk.gray(' '),
|
|
|
|
|
chalk.gray(' ___ '),
|
|
|
|
|
chalk.gray(' / \\ '),
|
|
|
|
|
chalk.gray(' | RIP | '),
|
|
|
|
|
chalk.gray(' __|_____|__ '),
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
chalk.gray(' ___ '),
|
|
|
|
|
chalk.gray(' / \\ '),
|
|
|
|
|
chalk.gray(' | RIP | '),
|
|
|
|
|
chalk.gray(' | | '),
|
|
|
|
|
chalk.gray(' __|_____|__ '),
|
|
|
|
|
]
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const ZOMBIE_FRAMES = [
|
|
|
|
|
[
|
|
|
|
|
chalk.gray(' ___ '),
|
|
|
|
|
chalk.gray(' / \\ '),
|
|
|
|
|
chalk.gray(' | RIP | '),
|
|
|
|
|
chalk.gray(' | | '),
|
|
|
|
|
chalk.gray(' __|_____|__ '),
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
chalk.gray(' ___ '),
|
|
|
|
|
chalk.gray(' / \\ '),
|
|
|
|
|
chalk.gray(' | RIP | '),
|
|
|
|
|
chalk.gray(' | | '),
|
|
|
|
|
chalk.gray(' __|_ ') + chalk.green('.') + chalk.gray(' _|__ '),
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
chalk.gray(' ___ '),
|
|
|
|
|
chalk.gray(' / \\ '),
|
|
|
|
|
chalk.gray(' | RIP | '),
|
|
|
|
|
chalk.gray(' | ') + chalk.green('^') + chalk.gray(' | '),
|
|
|
|
|
chalk.gray(' __|_') + chalk.green('/ \\') + chalk.gray('_|__ '),
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
chalk.gray(' ___ '),
|
|
|
|
|
chalk.gray(' / \\ '),
|
|
|
|
|
chalk.gray(' | ') + chalk.green('o_o') + chalk.gray(' | '),
|
|
|
|
|
chalk.gray(' | ') + chalk.green('/|\\') + chalk.gray(' | '),
|
|
|
|
|
chalk.gray(' __|_ ') + chalk.green('|') + chalk.gray(' _|__ '),
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
chalk.gray(' ___ '),
|
|
|
|
|
chalk.gray(' /') + chalk.green('o_o') + chalk.gray('\\ '),
|
|
|
|
|
chalk.gray(' | ') + chalk.green('/|\\') + chalk.gray(' | '),
|
|
|
|
|
chalk.gray(' | ') + chalk.green('|') + chalk.gray(' | '),
|
|
|
|
|
chalk.gray(' __|_') + chalk.green('/ \\') + chalk.gray('_|__ '),
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
chalk.gray(' \\') + chalk.green('o_o') + chalk.gray('/ '),
|
|
|
|
|
chalk.gray(' / ') + chalk.green('|') + chalk.gray(' \\ '),
|
|
|
|
|
chalk.gray(' | ') + chalk.green('/ \\') + chalk.gray(' | '),
|
|
|
|
|
chalk.gray(' | | '),
|
|
|
|
|
chalk.gray(' __|_____|__ '),
|
|
|
|
|
]
|
|
|
|
|
];
|
|
|
|
|
|
2026-06-01 00:59:22 +00:00
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 23:34:20 +00:00
|
|
|
// Utility to safely truncate strings
|
2026-06-01 00:59:22 +00:00
|
|
|
function truncate(str: string, length: number): string {
|
|
|
|
|
if (str.length <= length) return str;
|
2026-06-01 23:34:20 +00:00
|
|
|
return str.substring(0, length - 1) + '…';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Utility to format seconds into h m format
|
|
|
|
|
function formatTime(seconds?: number): string {
|
|
|
|
|
if (!seconds || seconds <= 0) return '0m';
|
|
|
|
|
const h = Math.floor(seconds / 3600);
|
|
|
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
|
|
|
if (h > 0 && m > 0) return `${h}h ${m}m`;
|
|
|
|
|
if (h > 0) return `${h}h`;
|
|
|
|
|
return `${m}m`;
|
2026-06-01 00:59:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-01 02:02:23 +00:00
|
|
|
/**
|
|
|
|
|
* Helper to strip ANSI escape codes from a string for accurate length calculations.
|
|
|
|
|
*/
|
|
|
|
|
function stripAnsi(str: string): string {
|
|
|
|
|
return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 00:59:22 +00:00
|
|
|
/**
|
|
|
|
|
* TUI State Controller and Render Engine
|
|
|
|
|
*/
|
|
|
|
|
export class TuiEngine {
|
|
|
|
|
private state: AppState;
|
|
|
|
|
private onQuit: () => void = () => {};
|
|
|
|
|
private activeSearchInput: boolean = false;
|
|
|
|
|
private searchInputBuffer: string = '';
|
2026-06-02 00:36:39 +00:00
|
|
|
private animationFrame: number = 0;
|
|
|
|
|
private animationInterval: NodeJS.Timeout | null = null;
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
2026-06-01 02:40:52 +00:00
|
|
|
// Enter alternate screen buffer and hide standard cursor
|
|
|
|
|
process.stdout.write('\x1B[?1049h\x1B[?25l');
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
// 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();
|
2026-06-02 00:36:39 +00:00
|
|
|
if (this.animationInterval) {
|
|
|
|
|
clearInterval(this.animationInterval);
|
|
|
|
|
this.animationInterval = null;
|
|
|
|
|
}
|
2026-06-01 02:40:52 +00:00
|
|
|
// Exit alternate screen buffer and show standard cursor
|
|
|
|
|
process.stdout.write('\x1B[?1049l\x1B[?25h');
|
2026-06-01 00:59:22 +00:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 23:20:58 +00:00
|
|
|
/**
|
|
|
|
|
* Fetches the current single issue and its comments again.
|
|
|
|
|
*/
|
|
|
|
|
private async reloadSingleIssue() {
|
|
|
|
|
if (!this.state.selectedIssue) return;
|
|
|
|
|
this.state.loading = true;
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.startSpinner();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const issue = await fetchIssue(this.state.config, this.state.selectedIssue.number);
|
|
|
|
|
this.state.selectedIssue = issue;
|
|
|
|
|
|
|
|
|
|
// Update in the main list as well
|
|
|
|
|
const listIndex = this.state.issues.findIndex(i => i.number === issue.number);
|
|
|
|
|
if (listIndex !== -1) {
|
|
|
|
|
this.state.issues[listIndex] = issue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.loadComments(issue);
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
this.state.error = `Failed to reload issue: ${err.message}`;
|
|
|
|
|
} finally {
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
this.stopSpinner();
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 00:59:22 +00:00
|
|
|
/**
|
|
|
|
|
* 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);
|
2026-06-01 02:02:23 +00:00
|
|
|
} else if (this.state.screen === 'repo-picker') {
|
|
|
|
|
this.handleRepoPickerKeypress(str, key);
|
2026-06-01 00:59:22 +00:00
|
|
|
} 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);
|
2026-06-01 03:06:53 +00:00
|
|
|
} else if (this.state.screen === 'create-issue') {
|
|
|
|
|
this.handleCreateIssueKeypress(str, key);
|
2026-06-01 03:18:12 +00:00
|
|
|
} else if (this.state.screen === 'add-comment') {
|
|
|
|
|
this.handleAddCommentKeypress(str, key);
|
2026-06-01 23:15:01 +00:00
|
|
|
} else if (this.state.screen === 'edit-issue') {
|
|
|
|
|
this.handleEditIssueKeypress(str, key);
|
2026-06-01 23:34:20 +00:00
|
|
|
} else if (this.state.screen === 'add-time') {
|
|
|
|
|
this.handleAddTimeKeypress(str, key);
|
2026-06-02 00:15:30 +00:00
|
|
|
} else if (this.state.screen === 'confirm-state-change') {
|
|
|
|
|
this.handleConfirmStateChangeKeypress(str, key);
|
2026-06-02 18:41:33 +00:00
|
|
|
} else if (this.state.screen === 'set-assignees') {
|
|
|
|
|
this.handleSetAssigneesKeypress(str, key);
|
2026-06-01 00:59:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Key handling for Setup form
|
|
|
|
|
*/
|
|
|
|
|
private async handleSetupKeypress(str: string, key: any) {
|
|
|
|
|
const form = this.state.setupForm;
|
2026-06-01 02:27:57 +00:00
|
|
|
const fields: Array<'url' | 'userid' | 'token' | 'saveConfig'> = ['url', 'userid', 'token', 'saveConfig'];
|
2026-06-01 00:59:22 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 02:27:57 +00:00
|
|
|
if (str === ' ') {
|
|
|
|
|
if (form.activeField === 'saveConfig') {
|
|
|
|
|
form.saveConfig = !form.saveConfig;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 00:59:22 +00:00
|
|
|
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
|
2026-06-01 02:27:57 +00:00
|
|
|
if (form.activeField === 'saveConfig') {
|
|
|
|
|
form.saveConfig = !form.saveConfig;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 00:59:22 +00:00
|
|
|
// Submit form
|
|
|
|
|
if (!form.url.trim()) {
|
|
|
|
|
this.state.error = 'Instance URL is required.';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-01 02:02:23 +00:00
|
|
|
if (!form.userid.trim()) {
|
|
|
|
|
this.state.error = 'User ID / Username is required.';
|
2026-06-01 00:59:22 +00:00
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.state.loading = true;
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-01 02:02:23 +00:00
|
|
|
const repos = await authenticateAndFetchRepos(form.url, form.userid, form.token);
|
|
|
|
|
|
|
|
|
|
this.state.repos = repos;
|
|
|
|
|
this.state.selectedRepoIndex = 0;
|
|
|
|
|
this.state.repoSearchQuery = '';
|
|
|
|
|
this.state.repoPickerActiveSearch = false;
|
|
|
|
|
|
|
|
|
|
if (repos.length === 0) {
|
|
|
|
|
throw new Error('No repositories found for the provided credentials.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Go to repo picker screen!
|
|
|
|
|
this.state.screen = 'repo-picker';
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
this.render();
|
2026-06-01 00:59:22 +00:00
|
|
|
} 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);
|
2026-06-01 02:02:23 +00:00
|
|
|
if (form.activeField === 'userid') form.userid = form.userid.slice(0, -1);
|
2026-06-01 00:59:22 +00:00
|
|
|
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;
|
2026-06-01 02:02:23 +00:00
|
|
|
if (form.activeField === 'userid') form.userid += str;
|
2026-06-01 00:59:22 +00:00
|
|
|
if (form.activeField === 'token') form.token += str;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 02:02:23 +00:00
|
|
|
/**
|
|
|
|
|
* Key handling for Repository Picker screen
|
|
|
|
|
*/
|
|
|
|
|
private handleRepoPickerKeypress(str: string, key: any) {
|
|
|
|
|
if (this.state.loading) return;
|
|
|
|
|
|
|
|
|
|
const filteredRepos = this.state.repos.filter(r =>
|
|
|
|
|
r.full_name.toLowerCase().includes(this.state.repoSearchQuery.toLowerCase()) ||
|
|
|
|
|
(r.description && r.description.toLowerCase().includes(this.state.repoSearchQuery.toLowerCase()))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (this.state.repoPickerActiveSearch) {
|
|
|
|
|
if ((key && key.name === 'escape') || str === '\u001b') {
|
|
|
|
|
this.state.repoPickerActiveSearch = false;
|
|
|
|
|
this.state.repoSearchQuery = '';
|
|
|
|
|
this.state.selectedRepoIndex = 0;
|
|
|
|
|
process.stdout.write('\x1B[?25l'); // Hide cursor
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
|
|
|
|
|
this.state.repoPickerActiveSearch = false;
|
|
|
|
|
process.stdout.write('\x1B[?25l'); // Hide cursor
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'backspace') {
|
|
|
|
|
this.state.repoSearchQuery = this.state.repoSearchQuery.slice(0, -1);
|
|
|
|
|
this.state.selectedRepoIndex = 0;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) {
|
|
|
|
|
this.state.repoSearchQuery += str;
|
|
|
|
|
this.state.selectedRepoIndex = 0;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Search inactive keypresses
|
|
|
|
|
if ((key && key.name === 'escape') || str === '\u001b') {
|
|
|
|
|
this.state.screen = 'setup';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'up') {
|
|
|
|
|
if (this.state.selectedRepoIndex > 0) {
|
|
|
|
|
this.state.selectedRepoIndex--;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'down') {
|
|
|
|
|
if (this.state.selectedRepoIndex < filteredRepos.length - 1) {
|
|
|
|
|
this.state.selectedRepoIndex++;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str === '/') {
|
|
|
|
|
this.state.repoPickerActiveSearch = true;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
|
|
|
|
|
if (filteredRepos.length > 0 && this.state.selectedRepoIndex < filteredRepos.length) {
|
|
|
|
|
const selectedRepo = filteredRepos[this.state.selectedRepoIndex];
|
|
|
|
|
const parts = selectedRepo.full_name.split('/');
|
|
|
|
|
|
|
|
|
|
this.state.config = {
|
|
|
|
|
url: normalizeUrl(this.state.setupForm.url),
|
|
|
|
|
token: this.state.setupForm.token.trim() || null,
|
|
|
|
|
userid: this.state.setupForm.userid.trim(),
|
|
|
|
|
owner: parts[0],
|
|
|
|
|
repo: parts[1],
|
|
|
|
|
};
|
2026-06-01 02:27:57 +00:00
|
|
|
|
|
|
|
|
if (this.state.setupForm.saveConfig) {
|
|
|
|
|
saveGlobalConfig({
|
|
|
|
|
url: this.state.config.url,
|
|
|
|
|
userid: this.state.config.userid,
|
|
|
|
|
token: this.state.config.token,
|
|
|
|
|
repo: `${parts[0]}/${parts[1]}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-01 02:02:23 +00:00
|
|
|
|
|
|
|
|
this.state.screen = 'list';
|
|
|
|
|
this.state.currentPage = 1;
|
|
|
|
|
this.state.selectedIssueIndex = 0;
|
|
|
|
|
this.state.searchQuery = '';
|
|
|
|
|
this.loadIssues();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 00:59:22 +00:00
|
|
|
/**
|
|
|
|
|
* 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') {
|
2026-06-01 02:02:23 +00:00
|
|
|
this.stop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str === 'o' || str === 'O') {
|
|
|
|
|
this.state.screen = 'setup';
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
2026-06-01 00:59:22 +00:00
|
|
|
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') {
|
2026-06-02 18:41:33 +00:00
|
|
|
// Cycle sorts: created -> updated -> comments -> assignees
|
|
|
|
|
const fields: Array<'created' | 'updated' | 'comments' | 'assignees'> = ['created', 'updated', 'comments', 'assignees'];
|
2026-06-01 00:59:22 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-01 03:06:53 +00:00
|
|
|
|
|
|
|
|
if (str === 'c' || str === 'C') {
|
|
|
|
|
// Navigate to Create Issue Screen
|
|
|
|
|
this.state.screen = 'create-issue';
|
|
|
|
|
this.state.createIssueForm.title = '';
|
|
|
|
|
this.state.createIssueForm.body = '';
|
|
|
|
|
this.state.createIssueForm.activeField = 'title';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 02:02:23 +00:00
|
|
|
if (str === 'o' || str === 'O') {
|
|
|
|
|
this.state.screen = 'setup';
|
|
|
|
|
this.state.selectedIssue = null;
|
|
|
|
|
this.state.selectedIssueComments = [];
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-01 03:18:12 +00:00
|
|
|
|
|
|
|
|
if (str === 'c' || str === 'C') {
|
|
|
|
|
this.state.screen = 'add-comment';
|
|
|
|
|
this.state.addCommentForm.body = '';
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-01 02:02:23 +00:00
|
|
|
|
2026-06-01 23:15:01 +00:00
|
|
|
if (str === 'e' || str === 'E') {
|
|
|
|
|
if (this.state.selectedIssue) {
|
|
|
|
|
this.state.screen = 'edit-issue';
|
|
|
|
|
this.state.editIssueForm.title = this.state.selectedIssue.title;
|
|
|
|
|
this.state.editIssueForm.body = this.state.selectedIssue.body;
|
|
|
|
|
this.state.editIssueForm.activeField = 'title';
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-01 23:20:58 +00:00
|
|
|
|
|
|
|
|
if (str === 'r' || str === 'R') {
|
|
|
|
|
if (this.state.selectedIssue) {
|
|
|
|
|
this.reloadSingleIssue();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-01 23:15:01 +00:00
|
|
|
|
2026-06-01 23:34:20 +00:00
|
|
|
if (str === 't' || str === 'T') {
|
|
|
|
|
if (this.state.selectedIssue) {
|
|
|
|
|
this.state.screen = 'add-time';
|
|
|
|
|
this.state.addTimeForm.timeInput = '';
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:15:30 +00:00
|
|
|
if (str === 'x' || str === 'X') {
|
|
|
|
|
if (this.state.selectedIssue) {
|
|
|
|
|
this.state.screen = 'confirm-state-change';
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 18:41:33 +00:00
|
|
|
if (str === 'a' || str === 'A') {
|
|
|
|
|
if (this.state.selectedIssue) {
|
|
|
|
|
this.state.screen = 'set-assignees';
|
|
|
|
|
this.state.assigneesInput = (this.state.selectedIssue.assignees || []).map(u => u.login).join(', ');
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 00:59:22 +00:00
|
|
|
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;
|
|
|
|
|
|
2026-06-01 02:40:52 +00:00
|
|
|
// Clear terminal screen, clear scrollback buffer, and reset cursor position
|
|
|
|
|
process.stdout.write('\x1B[2J\x1B[3J\x1B[H');
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
if (this.state.screen === 'setup') {
|
|
|
|
|
this.renderSetupScreen(cols, rows);
|
2026-06-01 02:02:23 +00:00
|
|
|
} else if (this.state.screen === 'repo-picker') {
|
|
|
|
|
this.renderRepoPickerScreen(cols, rows);
|
2026-06-01 00:59:22 +00:00
|
|
|
} else if (this.state.screen === 'list') {
|
|
|
|
|
this.renderListScreen(cols, rows);
|
|
|
|
|
} else if (this.state.screen === 'details') {
|
|
|
|
|
this.renderDetailsScreen(cols, rows);
|
2026-06-01 03:06:53 +00:00
|
|
|
} else if (this.state.screen === 'create-issue') {
|
|
|
|
|
this.renderCreateIssueScreen(cols, rows);
|
2026-06-01 03:18:12 +00:00
|
|
|
} else if (this.state.screen === 'add-comment') {
|
|
|
|
|
this.renderAddCommentScreen(cols, rows);
|
2026-06-01 23:15:01 +00:00
|
|
|
} else if (this.state.screen === 'edit-issue') {
|
|
|
|
|
this.renderEditIssueScreen(cols, rows);
|
2026-06-01 23:34:20 +00:00
|
|
|
} else if (this.state.screen === 'add-time') {
|
|
|
|
|
this.renderAddTimeScreen(cols, rows);
|
2026-06-02 00:15:30 +00:00
|
|
|
} else if (this.state.screen === 'confirm-state-change') {
|
|
|
|
|
this.renderConfirmStateChangeScreen(cols, rows);
|
2026-06-02 00:36:39 +00:00
|
|
|
} else if (this.state.screen === 'animating-close') {
|
|
|
|
|
this.renderAnimationScreen(cols, rows, GRAVESTONE_FRAMES);
|
|
|
|
|
} else if (this.state.screen === 'animating-reopen') {
|
|
|
|
|
this.renderAnimationScreen(cols, rows, ZOMBIE_FRAMES);
|
2026-06-02 18:41:33 +00:00
|
|
|
} else if (this.state.screen === 'set-assignees') {
|
|
|
|
|
this.renderSetAssigneesScreen(cols, rows);
|
2026-06-01 00:59:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:36:39 +00:00
|
|
|
private renderAnimationScreen(cols: number, rows: number, frames: string[][]) {
|
|
|
|
|
const frame = frames[Math.min(this.animationFrame, frames.length - 1)];
|
|
|
|
|
const paddingRows = Math.max(0, Math.floor((rows - frame.length) / 2));
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < paddingRows; i++) console.log(' '.repeat(cols));
|
|
|
|
|
|
|
|
|
|
for (const line of frame) {
|
|
|
|
|
const plainLen = stripAnsi(line).length;
|
|
|
|
|
const padLeft = Math.max(0, Math.floor((cols - plainLen) / 2));
|
|
|
|
|
console.log(' '.repeat(padLeft) + line);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const remainingRows = Math.max(0, rows - paddingRows - frame.length - 1);
|
|
|
|
|
for (let i = 0; i < remainingRows; i++) console.log(' '.repeat(cols));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 00:59:22 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-01 02:27:57 +00:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-01 00:59:22 +00:00
|
|
|
lines.push(renderField('Gitea URL', form.url, form.activeField === 'url'));
|
|
|
|
|
lines.push(border + ' '.repeat(width - 2) + border);
|
2026-06-01 02:02:23 +00:00
|
|
|
lines.push(renderField('User ID', form.userid, form.activeField === 'userid'));
|
|
|
|
|
lines.push(border + ' ' + chalk.gray('Forgejo/Gitea username (e.g. gitea_admin)').padEnd(width - 5) + border);
|
2026-06-01 00:59:22 +00:00
|
|
|
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);
|
2026-06-01 02:27:57 +00:00
|
|
|
lines.push(renderCheckbox('Save to config', form.saveConfig, form.activeField === 'saveConfig'));
|
|
|
|
|
lines.push(border + ' ' + chalk.gray('Saves settings to ~/.config/fjtui/fjtui.json').padEnd(width - 5) + border);
|
|
|
|
|
lines.push(border + ' '.repeat(width - 2) + border);
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
// 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]) : ' ';
|
2026-06-01 02:27:57 +00:00
|
|
|
const prompt = this.state.loading ? ' Connecting to instance...' : ' [Tab/Arrows] Navigate [Space/Enter] Toggle Checkbox [Enter] Connect [Esc] Quit';
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
// 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
|
2026-06-01 02:02:23 +00:00
|
|
|
if (form.activeField === 'userid') activeRowOffset = vertPadding + 8;
|
|
|
|
|
if (form.activeField === 'token') activeRowOffset = vertPadding + 11;
|
2026-06-01 02:27:57 +00:00
|
|
|
if (form.activeField === 'saveConfig') activeRowOffset = vertPadding + 14;
|
2026-06-01 00:59:22 +00:00
|
|
|
|
2026-06-01 02:27:57 +00:00
|
|
|
let cursorCol = Math.max(0, Math.floor((cols - width) / 2));
|
|
|
|
|
if (form.activeField === 'saveConfig') {
|
|
|
|
|
cursorCol += 24; // positions cursor inside the checkbox brackets [ ]
|
|
|
|
|
} else {
|
|
|
|
|
const activeValue = form.activeField === 'token' ? '*'.repeat(form.token.length) : form[form.activeField];
|
|
|
|
|
cursorCol += 25 + activeValue.length;
|
|
|
|
|
}
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
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?:\/\//, '');
|
2026-06-01 02:49:26 +00:00
|
|
|
const rightHeader = '';
|
|
|
|
|
const rightLen = 0;
|
2026-06-01 02:40:52 +00:00
|
|
|
|
2026-06-01 02:49:26 +00:00
|
|
|
// We want the total line length to fit exactly within 'cols'
|
|
|
|
|
const maxLeftWidth = cols - 1;
|
2026-06-01 02:40:52 +00:00
|
|
|
|
|
|
|
|
// Spinner plain length is 2 if loading, 0 otherwise
|
|
|
|
|
const spinnerPlainLen = this.state.loading ? 2 : 0;
|
|
|
|
|
const titlePlain = 'Forgejo Issue Explorer';
|
|
|
|
|
const titleColor = chalk.bold.hex('#4A90E2')(titlePlain);
|
2026-06-01 00:59:22 +00:00
|
|
|
|
2026-06-01 02:40:52 +00:00
|
|
|
const instanceColor = ` ─ ${chalk.bold.white(instanceName)}`;
|
|
|
|
|
const instancePlain = ` ─ ${instanceName}`;
|
|
|
|
|
|
|
|
|
|
let statsColor = '';
|
|
|
|
|
let statsPlain = '';
|
|
|
|
|
let shortStatsColor = '';
|
|
|
|
|
let shortStatsPlain = '';
|
|
|
|
|
|
2026-06-01 00:59:22 +00:00
|
|
|
if (this.state.totalIssuesCount > 0) {
|
|
|
|
|
const totalPages = Math.ceil(this.state.totalIssuesCount / this.state.issuesPerPage);
|
2026-06-01 02:40:52 +00:00
|
|
|
statsColor = ` ─ Page ${chalk.bold.white(this.state.currentPage)} of ${chalk.bold.white(totalPages)} (${chalk.yellow(this.state.totalIssuesCount)} matching)`;
|
|
|
|
|
statsPlain = ` ─ Page ${this.state.currentPage} of ${totalPages} (${this.state.totalIssuesCount} matching)`;
|
|
|
|
|
|
|
|
|
|
shortStatsColor = ` ─ P. ${chalk.bold.white(this.state.currentPage)}/${chalk.bold.white(totalPages)} (${chalk.yellow(this.state.totalIssuesCount)})`;
|
|
|
|
|
shortStatsPlain = ` ─ P. ${this.state.currentPage}/${totalPages} (${this.state.totalIssuesCount})`;
|
2026-06-01 00:59:22 +00:00
|
|
|
} else if (!this.state.loading) {
|
2026-06-01 02:40:52 +00:00
|
|
|
statsColor = ' ─ (0 issues found)';
|
|
|
|
|
statsPlain = ' ─ (0 issues found)';
|
|
|
|
|
|
|
|
|
|
shortStatsColor = ' ─ (0)';
|
|
|
|
|
shortStatsPlain = ' ─ (0)';
|
2026-06-01 00:59:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-01 02:40:52 +00:00
|
|
|
let leftHeader = '';
|
|
|
|
|
|
|
|
|
|
// Attempt 1: Full header: spinner + Title + instance + stats
|
|
|
|
|
if (1 + spinnerPlainLen + titlePlain.length + instancePlain.length + statsPlain.length <= maxLeftWidth) {
|
|
|
|
|
leftHeader = ` ${spinnerStr}${titleColor}${instanceColor}${statsColor}`;
|
|
|
|
|
}
|
|
|
|
|
// Attempt 2: Drop instance, keep full stats
|
|
|
|
|
else if (1 + spinnerPlainLen + titlePlain.length + statsPlain.length <= maxLeftWidth) {
|
|
|
|
|
leftHeader = ` ${spinnerStr}${titleColor}${statsColor}`;
|
|
|
|
|
}
|
|
|
|
|
// Attempt 3: Drop instance, use short stats
|
|
|
|
|
else if (1 + spinnerPlainLen + titlePlain.length + shortStatsPlain.length <= maxLeftWidth) {
|
|
|
|
|
leftHeader = ` ${spinnerStr}${titleColor}${shortStatsColor}`;
|
|
|
|
|
}
|
|
|
|
|
// Attempt 4: Drop stats, keep title + shortened instance if it fits
|
|
|
|
|
else if (1 + spinnerPlainLen + titlePlain.length + instancePlain.length <= maxLeftWidth) {
|
|
|
|
|
leftHeader = ` ${spinnerStr}${titleColor}${instanceColor}`;
|
|
|
|
|
}
|
|
|
|
|
// Attempt 5: Just Title
|
|
|
|
|
else {
|
|
|
|
|
leftHeader = ` ${spinnerStr}${titleColor}`;
|
|
|
|
|
}
|
2026-06-01 02:27:57 +00:00
|
|
|
|
|
|
|
|
const leftLen = stripAnsi(leftHeader).length;
|
|
|
|
|
let spacesCount = cols - leftLen - rightLen;
|
|
|
|
|
if (spacesCount < 1) spacesCount = 1;
|
|
|
|
|
|
2026-06-01 02:46:36 +00:00
|
|
|
console.log(leftHeader + ' '.repeat(spacesCount - 1) + rightHeader);
|
2026-06-01 00:59:22 +00:00
|
|
|
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
|
|
|
|
|
|
|
|
|
|
// Column widths
|
|
|
|
|
const idWidth = 6;
|
|
|
|
|
const typeWidth = 5;
|
|
|
|
|
const stateWidth = 7;
|
|
|
|
|
const authorWidth = 14;
|
2026-06-02 18:41:33 +00:00
|
|
|
const assigneesWidth = 14;
|
2026-06-01 00:59:22 +00:00
|
|
|
const createdWidth = 12;
|
|
|
|
|
const commentsWidth = 6;
|
2026-06-01 23:34:20 +00:00
|
|
|
const timeWidth = 7;
|
2026-06-02 18:41:33 +00:00
|
|
|
// Title takes all remaining space. There are 9 columns + 10 borders + 9 spaces = 19 extra chars
|
|
|
|
|
const titleWidth = Math.max(10, cols - idWidth - typeWidth - stateWidth - authorWidth - assigneesWidth - createdWidth - commentsWidth - timeWidth - 19);
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
// 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 + ' ' +
|
2026-06-02 18:41:33 +00:00
|
|
|
padHeader('Assignees', assigneesWidth) + borderCh + ' ' +
|
2026-06-01 00:59:22 +00:00
|
|
|
padHeader('Created', createdWidth) + borderCh + ' ' +
|
2026-06-01 23:34:20 +00:00
|
|
|
padHeader('Coms', commentsWidth) + borderCh + ' ' +
|
|
|
|
|
padHeader('Time', timeWidth) + borderCh
|
2026-06-01 00:59:22 +00:00
|
|
|
);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
|
2026-06-01 02:46:36 +00:00
|
|
|
// Table Content Height available (rows - 10 ensures total printed lines is rows - 2, preventing terminal scrolling)
|
|
|
|
|
const tableHeight = rows - 10;
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
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);
|
2026-06-02 18:41:33 +00:00
|
|
|
const assigneesNames = (issue.assignees || []).map(u => u.login).join(',');
|
|
|
|
|
const assigneesStr = truncate(assigneesNames, assigneesWidth).padEnd(assigneesWidth);
|
2026-06-01 00:59:22 +00:00
|
|
|
const createdStr = formatDate(issue.created_at).substring(0, 10).padEnd(createdWidth);
|
|
|
|
|
const commentsStr = String(issue.comments).padEnd(commentsWidth);
|
2026-06-01 23:34:20 +00:00
|
|
|
const timeStr = formatTime(issue.total_tracked_time).padEnd(timeWidth);
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
let rowText =
|
|
|
|
|
' ' + idStr + borderCh +
|
|
|
|
|
' ' + typeStr + borderCh +
|
|
|
|
|
' ' + stateStr + borderCh +
|
|
|
|
|
' ' + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh +
|
|
|
|
|
' ' + authorStr + borderCh +
|
2026-06-02 18:41:33 +00:00
|
|
|
' ' + assigneesStr + borderCh +
|
2026-06-01 00:59:22 +00:00
|
|
|
' ' + createdStr + borderCh +
|
2026-06-01 23:34:20 +00:00
|
|
|
' ' + commentsStr + borderCh +
|
|
|
|
|
' ' + timeStr + borderCh;
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
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}`;
|
2026-06-01 23:45:27 +00:00
|
|
|
const filtersPlainLen = stripAnsi(filtersText).length;
|
|
|
|
|
const padding = Math.max(0, cols - 2 - filtersPlainLen);
|
|
|
|
|
console.log(borderCh + filtersText + ' '.repeat(padding) + borderCh);
|
2026-06-01 00:59:22 +00:00
|
|
|
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
|
|
|
|
|
|
|
|
|
|
// Keyboard controls help line
|
2026-06-01 03:06:53 +00:00
|
|
|
const fullHelpLine = ' [↑/↓] Navigate [Enter] View [C] Create [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [O] Settings [Esc] Quit';
|
2026-06-01 02:49:26 +00:00
|
|
|
const repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `);
|
|
|
|
|
const repoLen = stripAnsi(repoStr).length;
|
|
|
|
|
|
|
|
|
|
// We want total length to be cols - 1 to prevent any wrapping
|
|
|
|
|
const maxHelpLen = cols - repoLen - 1;
|
|
|
|
|
let helpLinePlain = fullHelpLine;
|
|
|
|
|
if (helpLinePlain.length > maxHelpLen) {
|
|
|
|
|
helpLinePlain = helpLinePlain.substring(0, maxHelpLen - 3) + '...';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const spaces = cols - helpLinePlain.length - repoLen - 1;
|
|
|
|
|
process.stdout.write(chalk.gray(helpLinePlain) + ' '.repeat(Math.max(0, spaces)) + repoStr);
|
2026-06-01 00:59:22 +00:00
|
|
|
|
|
|
|
|
// 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] ');
|
2026-06-01 02:27:57 +00:00
|
|
|
const rightHeader = `repo: ${chalk.bold.cyan(`${this.state.config.owner}/${this.state.config.repo}`)} `;
|
|
|
|
|
const rightLen = stripAnsi(rightHeader).length;
|
|
|
|
|
|
|
|
|
|
const maxLeftWidth = cols - rightLen - 2;
|
|
|
|
|
const prefix = ` ${typeTag}#${issue.number} ─ `;
|
|
|
|
|
const prefixLen = stripAnsi(prefix).length;
|
|
|
|
|
const titleWidth = Math.max(10, maxLeftWidth - prefixLen);
|
|
|
|
|
|
|
|
|
|
const leftHeader = prefix + chalk.bold.white(truncate(issue.title, titleWidth));
|
|
|
|
|
const leftLen = stripAnsi(leftHeader).length;
|
|
|
|
|
|
|
|
|
|
let spacesCount = cols - leftLen - rightLen;
|
|
|
|
|
if (spacesCount < 1) spacesCount = 1;
|
|
|
|
|
|
|
|
|
|
console.log(leftHeader + ' '.repeat(spacesCount) + rightHeader);
|
2026-06-01 00:59:22 +00:00
|
|
|
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(' ');
|
2026-06-02 18:41:33 +00:00
|
|
|
const assigneesStr = (issue.assignees || []).map(u => u.login).join(', ');
|
|
|
|
|
const assigneesDisp = assigneesStr ? chalk.magenta(assigneesStr) : chalk.gray('none');
|
2026-06-01 23:34:20 +00:00
|
|
|
const timeStr = formatTime(issue.total_tracked_time);
|
2026-06-01 00:59:22 +00:00
|
|
|
|
2026-06-02 18:41:33 +00:00
|
|
|
const metaLine = ` State: ${stateLabel} Author: ${chalk.cyan(issue.user.login)} Assignees: ${assigneesDisp} Created: ${formatDate(issue.created_at)} Updated: ${formatDate(issue.updated_at)} Time: ${chalk.yellow(timeStr)}`;
|
2026-06-01 23:40:04 +00:00
|
|
|
const metaPlainLen = stripAnsi(metaLine).length;
|
|
|
|
|
const padding = Math.max(0, cols - 2 - metaPlainLen);
|
|
|
|
|
console.log(borderCh + metaLine + ' '.repeat(padding) + borderCh);
|
|
|
|
|
|
2026-06-01 00:59:22 +00:00
|
|
|
if (labels) {
|
2026-06-01 23:40:04 +00:00
|
|
|
const labelsLine = ` Labels: ${labels}`;
|
|
|
|
|
const labelsPlainLen = stripAnsi(labelsLine).length;
|
|
|
|
|
const labelsPadding = Math.max(0, cols - 2 - labelsPlainLen);
|
|
|
|
|
console.log(borderCh + labelsLine + ' '.repeat(labelsPadding) + borderCh);
|
2026-06-01 00:59:22 +00:00
|
|
|
}
|
|
|
|
|
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
|
2026-06-01 02:40:52 +00:00
|
|
|
const metaHeight = labels ? 3 : 2; // Meta 1, optionally Meta 2 (Labels), and Separator
|
|
|
|
|
const displayHeight = rows - 5 - metaHeight; // Header: 1, Borders: 2, Meta lines, Help: 1, and -1 to avoid scrolling
|
2026-06-01 00:59:22 +00:00
|
|
|
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];
|
2026-06-02 00:02:10 +00:00
|
|
|
const linePlainLen = stripAnsi(line).length;
|
|
|
|
|
const padding = Math.max(0, cols - 4 - linePlainLen);
|
|
|
|
|
console.log(borderCh + ' ' + line + ' '.repeat(padding) + ' ' + borderCh);
|
2026-06-01 00:59:22 +00:00
|
|
|
} 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}%)`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:15:30 +00:00
|
|
|
const actionKey = issue.state === 'open' ? 'Close' : 'Reopen';
|
2026-06-02 18:41:33 +00:00
|
|
|
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}`);
|
2026-06-01 02:02:23 +00:00
|
|
|
process.stdout.write(helpLine);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Draw Repository Picker Screen
|
|
|
|
|
*/
|
|
|
|
|
private renderRepoPickerScreen(cols: number, rows: number) {
|
|
|
|
|
const title = 'SELECT REPOSITORY';
|
|
|
|
|
const subtitle = `Connected to ${normalizeUrl(this.state.setupForm.url).replace(/^https?:\/\//, '')} as ${this.state.setupForm.userid}`;
|
|
|
|
|
|
|
|
|
|
console.log(` ${chalk.bold.hex('#4A90E2')(title)} ─ ${chalk.gray(subtitle)}`);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
|
|
|
|
|
|
|
|
|
|
const borderCh = chalk.bold.hex('#4A90E2')('│');
|
|
|
|
|
|
|
|
|
|
// Filter repositories based on query
|
|
|
|
|
const filteredRepos = this.state.repos.filter(r =>
|
|
|
|
|
r.full_name.toLowerCase().includes(this.state.repoSearchQuery.toLowerCase()) ||
|
|
|
|
|
(r.description && r.description.toLowerCase().includes(this.state.repoSearchQuery.toLowerCase()))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const listHeight = rows - 7; // Header, borders, search, footer
|
|
|
|
|
|
|
|
|
|
// Scroll window calculation
|
|
|
|
|
let startIndex = 0;
|
|
|
|
|
if (this.state.selectedRepoIndex >= listHeight) {
|
|
|
|
|
startIndex = this.state.selectedRepoIndex - listHeight + 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (filteredRepos.length === 0) {
|
|
|
|
|
const msg = this.state.repoSearchQuery ? 'No repositories match your filter.' : 'No repositories found.';
|
|
|
|
|
const paddingRows = Math.floor((listHeight - 1) / 2);
|
|
|
|
|
for (let i = 0; i < paddingRows; i++) console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
|
|
|
|
|
console.log(borderCh + (' '.repeat(Math.max(0, Math.floor((cols - 2 - msg.length) / 2))) + chalk.gray(msg)).padEnd(cols - 2) + borderCh);
|
|
|
|
|
for (let i = 0; i < listHeight - paddingRows - 1; i++) console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
|
|
|
|
|
} else {
|
|
|
|
|
for (let i = 0; i < listHeight; i++) {
|
|
|
|
|
const repoIdx = i + startIndex;
|
|
|
|
|
if (repoIdx < filteredRepos.length) {
|
|
|
|
|
const repo = filteredRepos[repoIdx];
|
|
|
|
|
const isSelected = repoIdx === this.state.selectedRepoIndex;
|
|
|
|
|
|
|
|
|
|
const typeStr = repo.private ? chalk.bold.yellow('[Private]') : chalk.bold.green('[Public] ');
|
|
|
|
|
const nameStr = chalk.bold(repo.full_name);
|
|
|
|
|
const descStr = repo.description ? chalk.gray(` ─ ${truncate(repo.description, cols - repo.full_name.length - 20)}`) : '';
|
|
|
|
|
|
|
|
|
|
let lineContent = ` ${typeStr} ${nameStr}${descStr}`;
|
|
|
|
|
|
|
|
|
|
// Pad the line content properly to match terminal width
|
|
|
|
|
const visibleLen = stripAnsi(lineContent).length;
|
|
|
|
|
const padding = ' '.repeat(Math.max(0, cols - 4 - visibleLen));
|
|
|
|
|
lineContent = lineContent + padding;
|
|
|
|
|
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
lineContent = chalk.bgHex('#2E4E7E').white.bold(lineContent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(borderCh + ' ' + lineContent + ' ' + borderCh);
|
|
|
|
|
} else {
|
|
|
|
|
console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
|
|
|
|
|
// Filter status line
|
|
|
|
|
let filterLabel = this.state.repoSearchQuery ? chalk.yellow(`"${this.state.repoSearchQuery}"`) : chalk.gray('none');
|
|
|
|
|
if (this.state.repoPickerActiveSearch) {
|
|
|
|
|
filterLabel = chalk.inverse(this.state.repoSearchQuery + ' ');
|
|
|
|
|
}
|
|
|
|
|
const filterText = ` Filter: ${filterLabel}`;
|
|
|
|
|
console.log(borderCh + filterText.padEnd(cols - 2) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
|
|
|
|
|
|
|
|
|
|
const helpLine = chalk.gray(' [↑/↓] Navigate [Enter] Select [/] Filter [Esc] Back to Setup');
|
2026-06-01 00:59:22 +00:00
|
|
|
process.stdout.write(helpLine);
|
2026-06-01 02:02:23 +00:00
|
|
|
|
|
|
|
|
if (this.state.repoPickerActiveSearch) {
|
|
|
|
|
const searchCursorCol = 10 + this.state.repoSearchQuery.length;
|
|
|
|
|
process.stdout.write(`\x1B[${rows - 1};${searchCursorCol}H\x1B[?25h`); // Show cursor
|
|
|
|
|
}
|
2026-06-01 00:59:22 +00:00
|
|
|
}
|
2026-06-01 03:06:53 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Draw Create Issue Screen
|
|
|
|
|
*/
|
|
|
|
|
private renderCreateIssueScreen(cols: number, rows: number) {
|
|
|
|
|
const borderCh = chalk.bold.hex('#4A90E2')('│');
|
|
|
|
|
|
|
|
|
|
// Header
|
|
|
|
|
const title = ' Create New Issue ';
|
|
|
|
|
const leftLen = stripAnsi(title).length;
|
|
|
|
|
let leftHeader = chalk.bold.hex('#4A90E2')(title);
|
|
|
|
|
|
|
|
|
|
console.log(leftHeader + ' '.repeat(Math.max(1, cols - leftLen - 1)));
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
|
|
|
|
|
|
|
|
|
|
if (this.state.error) {
|
|
|
|
|
console.log(borderCh + chalk.red(` Error: ${this.state.error}`).padEnd(cols - 2) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const form = this.state.createIssueForm;
|
|
|
|
|
|
|
|
|
|
// Title Field
|
|
|
|
|
let titleLabel = ' Title: ';
|
|
|
|
|
let titleVisible = form.title;
|
|
|
|
|
if (titleVisible.length > cols - 13) {
|
|
|
|
|
titleVisible = titleVisible.substring(titleVisible.length - (cols - 13));
|
|
|
|
|
}
|
|
|
|
|
let titleContent = titleVisible.padEnd(cols - 12);
|
|
|
|
|
if (form.activeField === 'title') {
|
|
|
|
|
titleLabel = chalk.yellow(' Title: ');
|
|
|
|
|
titleContent = chalk.inverse(titleVisible + ' ') + ' '.repeat(Math.max(0, cols - 13 - titleVisible.length));
|
|
|
|
|
}
|
|
|
|
|
console.log(borderCh + titleLabel + titleContent + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
|
|
|
|
|
// Body Field
|
|
|
|
|
let bodyLabel = chalk.bold(' Body (markdown supported): ');
|
|
|
|
|
if (form.activeField === 'body') {
|
|
|
|
|
bodyLabel = chalk.yellow.bold(' Body (markdown supported): ');
|
|
|
|
|
}
|
|
|
|
|
console.log(borderCh + bodyLabel + ' '.repeat(cols - stripAnsi(bodyLabel).length - 2) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
|
|
|
|
|
// Body Content (multiline)
|
|
|
|
|
const errorOffset = this.state.error ? 2 : 0;
|
|
|
|
|
const bodyRows = rows - 10 - errorOffset; // available space for body
|
|
|
|
|
const lines = form.body.split('\n');
|
|
|
|
|
|
|
|
|
|
// Pagination for body if it gets too long
|
|
|
|
|
const startLine = Math.max(0, lines.length - bodyRows);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < bodyRows; i++) {
|
|
|
|
|
const lineIdx = startLine + i;
|
|
|
|
|
let lineContent = '';
|
|
|
|
|
if (lineIdx < lines.length) {
|
|
|
|
|
lineContent = lines[lineIdx];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let visLine = lineContent;
|
|
|
|
|
if (visLine.length > cols - 5) {
|
|
|
|
|
visLine = visLine.substring(visLine.length - (cols - 5));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (form.activeField === 'body' && lineIdx === lines.length - 1) {
|
|
|
|
|
visLine = visLine + chalk.inverse(' ') + ' '.repeat(Math.max(0, cols - 5 - stripAnsi(visLine).length));
|
|
|
|
|
} else {
|
|
|
|
|
visLine = visLine.padEnd(cols - 4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(borderCh + ' ' + visLine + ' ' + borderCh);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
|
|
|
|
|
|
|
|
|
|
// Help line
|
|
|
|
|
const helpLine = chalk.gray(' [Tab] Switch Fields [Ctrl+S] Submit [Esc] Cancel');
|
|
|
|
|
const repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `);
|
|
|
|
|
const repoLen = stripAnsi(repoStr).length;
|
|
|
|
|
|
|
|
|
|
const maxHelpLen = cols - repoLen - 1;
|
|
|
|
|
let helpLinePlain = helpLine;
|
|
|
|
|
if (stripAnsi(helpLinePlain).length > maxHelpLen) {
|
|
|
|
|
helpLinePlain = chalk.gray(stripAnsi(helpLinePlain).substring(0, maxHelpLen - 3) + '...');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const spaces = cols - stripAnsi(helpLinePlain).length - repoLen - 1;
|
|
|
|
|
process.stdout.write(helpLinePlain + ' '.repeat(Math.max(0, spaces)) + repoStr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle Keypress for Create Issue Screen
|
|
|
|
|
*/
|
|
|
|
|
private async handleCreateIssueKeypress(str: string, key: any) {
|
|
|
|
|
if (key && key.name === 'escape') {
|
|
|
|
|
this.state.screen = 'list';
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.ctrl && key.name === 's') {
|
|
|
|
|
// Submit issue
|
|
|
|
|
if (!this.state.createIssueForm.title.trim()) {
|
|
|
|
|
this.state.error = 'Title is required to create an issue.';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.state.loading = true;
|
|
|
|
|
this.render();
|
|
|
|
|
try {
|
|
|
|
|
await createIssue(this.state.config, this.state.createIssueForm.title, this.state.createIssueForm.body);
|
|
|
|
|
this.state.screen = 'list';
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
// Refresh issues
|
|
|
|
|
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 === 'tab') {
|
|
|
|
|
this.state.createIssueForm.activeField = this.state.createIssueForm.activeField === 'title' ? 'body' : 'title';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Text input handling
|
|
|
|
|
const activeField = this.state.createIssueForm.activeField;
|
|
|
|
|
let val = this.state.createIssueForm[activeField];
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'backspace') {
|
|
|
|
|
if (val.length > 0) {
|
|
|
|
|
this.state.createIssueForm[activeField] = val.slice(0, -1);
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'return') {
|
|
|
|
|
if (activeField === 'body') {
|
|
|
|
|
this.state.createIssueForm.body += '\n';
|
|
|
|
|
this.render();
|
|
|
|
|
} else {
|
|
|
|
|
// In title, return switches to body
|
|
|
|
|
this.state.createIssueForm.activeField = 'body';
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str && str.length === 1 && !key.ctrl && !key.meta) {
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.state.createIssueForm[activeField] += str;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-01 03:18:12 +00:00
|
|
|
|
|
|
|
|
private renderAddCommentScreen(cols: number, rows: number) {
|
|
|
|
|
const borderCh = chalk.bold.hex('#4A90E2')('│');
|
|
|
|
|
|
|
|
|
|
// Header
|
|
|
|
|
const title = ' Add Comment ';
|
|
|
|
|
const leftLen = stripAnsi(title).length;
|
|
|
|
|
let leftHeader = chalk.bold.hex('#4A90E2')(title);
|
|
|
|
|
|
|
|
|
|
console.log(leftHeader + ' '.repeat(Math.max(1, cols - leftLen - 1)));
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
|
|
|
|
|
|
|
|
|
|
if (this.state.error) {
|
|
|
|
|
console.log(borderCh + chalk.red(` Error: ${this.state.error}`).padEnd(cols - 2) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const form = this.state.addCommentForm;
|
|
|
|
|
|
|
|
|
|
// Body Field
|
|
|
|
|
const bodyLabel = chalk.yellow.bold(' Comment (markdown supported): ');
|
|
|
|
|
console.log(borderCh + bodyLabel + ' '.repeat(cols - stripAnsi(bodyLabel).length - 2) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
|
|
|
|
|
// Body Content (multiline)
|
|
|
|
|
const errorOffset = this.state.error ? 2 : 0;
|
|
|
|
|
const bodyRows = rows - 8 - errorOffset; // available space for body
|
|
|
|
|
const lines = form.body.split('\n');
|
|
|
|
|
|
|
|
|
|
// Pagination for body if it gets too long
|
|
|
|
|
const startLine = Math.max(0, lines.length - bodyRows);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < bodyRows; i++) {
|
|
|
|
|
const lineIdx = startLine + i;
|
|
|
|
|
let lineContent = '';
|
|
|
|
|
if (lineIdx < lines.length) {
|
|
|
|
|
lineContent = lines[lineIdx];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let visLine = lineContent;
|
|
|
|
|
if (visLine.length > cols - 5) {
|
|
|
|
|
visLine = visLine.substring(visLine.length - (cols - 5));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (lineIdx === lines.length - 1) {
|
|
|
|
|
visLine = visLine + chalk.inverse(' ') + ' '.repeat(Math.max(0, cols - 5 - stripAnsi(visLine).length));
|
|
|
|
|
} else {
|
|
|
|
|
visLine = visLine.padEnd(cols - 4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(borderCh + ' ' + visLine + ' ' + borderCh);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
|
|
|
|
|
|
|
|
|
|
// Help line
|
|
|
|
|
const helpLine = chalk.gray(' [Ctrl+S] Submit [Esc] Cancel');
|
|
|
|
|
const issueStr = this.state.selectedIssue ? chalk.bold.cyan(` Issue #${this.state.selectedIssue.number} `) : '';
|
|
|
|
|
const issueLen = stripAnsi(issueStr).length;
|
|
|
|
|
|
|
|
|
|
const maxHelpLen = cols - issueLen - 1;
|
|
|
|
|
let helpLinePlain = helpLine;
|
|
|
|
|
if (stripAnsi(helpLinePlain).length > maxHelpLen) {
|
|
|
|
|
helpLinePlain = chalk.gray(stripAnsi(helpLinePlain).substring(0, maxHelpLen - 3) + '...');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const spaces = cols - stripAnsi(helpLinePlain).length - issueLen - 1;
|
|
|
|
|
process.stdout.write(helpLinePlain + ' '.repeat(Math.max(0, spaces)) + issueStr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleAddCommentKeypress(str: string, key: any) {
|
|
|
|
|
if (key && key.name === 'escape') {
|
|
|
|
|
this.state.screen = 'details';
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.ctrl && key.name === 's') {
|
|
|
|
|
// Submit comment
|
|
|
|
|
if (!this.state.addCommentForm.body.trim()) {
|
|
|
|
|
this.state.error = 'Comment cannot be empty.';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.state.selectedIssue) {
|
|
|
|
|
this.state.error = 'No issue selected.';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.state.loading = true;
|
|
|
|
|
this.render();
|
|
|
|
|
try {
|
|
|
|
|
await createIssueComment(this.state.config, this.state.selectedIssue.number, this.state.addCommentForm.body);
|
|
|
|
|
this.state.screen = 'details';
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
// Refresh comments
|
|
|
|
|
this.loadComments(this.state.selectedIssue);
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
this.state.error = err.message;
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let val = this.state.addCommentForm.body;
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'backspace') {
|
|
|
|
|
if (val.length > 0) {
|
|
|
|
|
this.state.addCommentForm.body = val.slice(0, -1);
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'return') {
|
|
|
|
|
this.state.addCommentForm.body += '\n';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str && str.length === 1 && !key.ctrl && !key.meta) {
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.state.addCommentForm.body += str;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-01 23:15:01 +00:00
|
|
|
|
|
|
|
|
private renderEditIssueScreen(cols: number, rows: number) {
|
|
|
|
|
const borderCh = chalk.bold.hex('#4A90E2')('│');
|
|
|
|
|
|
|
|
|
|
// Header
|
|
|
|
|
const title = ' Edit Issue ';
|
|
|
|
|
const leftLen = stripAnsi(title).length;
|
|
|
|
|
let leftHeader = chalk.bold.hex('#4A90E2')(title);
|
|
|
|
|
|
|
|
|
|
console.log(leftHeader + ' '.repeat(Math.max(1, cols - leftLen - 1)));
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
|
|
|
|
|
|
|
|
|
|
if (this.state.error) {
|
|
|
|
|
console.log(borderCh + chalk.red(` Error: ${this.state.error}`).padEnd(cols - 2) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const form = this.state.editIssueForm;
|
|
|
|
|
|
|
|
|
|
// Title Field
|
|
|
|
|
let titleLabel = ' Title: ';
|
|
|
|
|
let titleVisible = form.title;
|
|
|
|
|
if (titleVisible.length > cols - 13) {
|
|
|
|
|
titleVisible = titleVisible.substring(titleVisible.length - (cols - 13));
|
|
|
|
|
}
|
|
|
|
|
let titleContent = titleVisible.padEnd(cols - 12);
|
|
|
|
|
if (form.activeField === 'title') {
|
|
|
|
|
titleLabel = chalk.yellow(' Title: ');
|
|
|
|
|
titleContent = chalk.inverse(titleVisible + ' ') + ' '.repeat(Math.max(0, cols - 13 - titleVisible.length));
|
|
|
|
|
}
|
|
|
|
|
console.log(borderCh + titleLabel + titleContent + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
|
|
|
|
|
// Body Field
|
|
|
|
|
let bodyLabel = chalk.bold(' Body (markdown supported): ');
|
|
|
|
|
if (form.activeField === 'body') {
|
|
|
|
|
bodyLabel = chalk.yellow.bold(' Body (markdown supported): ');
|
|
|
|
|
}
|
|
|
|
|
console.log(borderCh + bodyLabel + ' '.repeat(cols - stripAnsi(bodyLabel).length - 2) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
|
|
|
|
|
// Body Content (multiline)
|
|
|
|
|
const errorOffset = this.state.error ? 2 : 0;
|
|
|
|
|
const bodyRows = rows - 10 - errorOffset; // available space for body
|
|
|
|
|
const lines = form.body.split('\n');
|
|
|
|
|
|
|
|
|
|
// Pagination for body if it gets too long
|
|
|
|
|
const startLine = Math.max(0, lines.length - bodyRows);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < bodyRows; i++) {
|
|
|
|
|
const lineIdx = startLine + i;
|
|
|
|
|
let lineContent = '';
|
|
|
|
|
if (lineIdx < lines.length) {
|
|
|
|
|
lineContent = lines[lineIdx];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let visLine = lineContent;
|
|
|
|
|
if (visLine.length > cols - 5) {
|
|
|
|
|
visLine = visLine.substring(visLine.length - (cols - 5));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (form.activeField === 'body' && lineIdx === lines.length - 1) {
|
|
|
|
|
visLine = visLine + chalk.inverse(' ') + ' '.repeat(Math.max(0, cols - 5 - stripAnsi(visLine).length));
|
|
|
|
|
} else {
|
|
|
|
|
visLine = visLine.padEnd(cols - 4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(borderCh + ' ' + visLine + ' ' + borderCh);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
|
|
|
|
|
|
|
|
|
|
// Help line
|
|
|
|
|
const helpLine = chalk.gray(' [Tab] Switch Fields [Ctrl+S] Save [Esc] Cancel');
|
|
|
|
|
const issueStr = this.state.selectedIssue ? chalk.bold.cyan(` Issue #${this.state.selectedIssue.number} `) : '';
|
|
|
|
|
const issueLen = stripAnsi(issueStr).length;
|
|
|
|
|
|
|
|
|
|
const maxHelpLen = cols - issueLen - 1;
|
|
|
|
|
let helpLinePlain = helpLine;
|
|
|
|
|
if (stripAnsi(helpLinePlain).length > maxHelpLen) {
|
|
|
|
|
helpLinePlain = chalk.gray(stripAnsi(helpLinePlain).substring(0, maxHelpLen - 3) + '...');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const spaces = cols - stripAnsi(helpLinePlain).length - issueLen - 1;
|
|
|
|
|
process.stdout.write(helpLinePlain + ' '.repeat(Math.max(0, spaces)) + issueStr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleEditIssueKeypress(str: string, key: any) {
|
|
|
|
|
if (key && key.name === 'escape') {
|
|
|
|
|
this.state.screen = 'details';
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.ctrl && key.name === 's') {
|
|
|
|
|
// Save issue
|
|
|
|
|
if (!this.state.editIssueForm.title.trim()) {
|
|
|
|
|
this.state.error = 'Title is required.';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.state.selectedIssue) {
|
|
|
|
|
this.state.error = 'No issue selected.';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.state.loading = true;
|
|
|
|
|
this.render();
|
|
|
|
|
try {
|
|
|
|
|
const updatedIssue = await editIssue(this.state.config, this.state.selectedIssue.number, this.state.editIssueForm.title, this.state.editIssueForm.body);
|
|
|
|
|
|
|
|
|
|
// Update the list and selected issue with the new data
|
|
|
|
|
this.state.selectedIssue = updatedIssue;
|
|
|
|
|
const listIndex = this.state.issues.findIndex((i) => i.number === updatedIssue.number);
|
|
|
|
|
if (listIndex !== -1) {
|
|
|
|
|
this.state.issues[listIndex] = updatedIssue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.state.screen = 'details';
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
this.render();
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
this.state.error = err.message;
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'tab') {
|
|
|
|
|
this.state.editIssueForm.activeField = this.state.editIssueForm.activeField === 'title' ? 'body' : 'title';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Text input handling
|
|
|
|
|
const activeField = this.state.editIssueForm.activeField;
|
|
|
|
|
let val = this.state.editIssueForm[activeField];
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'backspace') {
|
|
|
|
|
if (val.length > 0) {
|
|
|
|
|
this.state.editIssueForm[activeField] = val.slice(0, -1);
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'return') {
|
|
|
|
|
if (activeField === 'body') {
|
|
|
|
|
this.state.editIssueForm.body += '\n';
|
|
|
|
|
this.render();
|
|
|
|
|
} else {
|
|
|
|
|
// In title, return switches to body
|
|
|
|
|
this.state.editIssueForm.activeField = 'body';
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str && str.length === 1 && !key.ctrl && !key.meta) {
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.state.editIssueForm[activeField] += str;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-01 23:34:20 +00:00
|
|
|
|
|
|
|
|
private renderAddTimeScreen(cols: number, rows: number) {
|
|
|
|
|
const borderCh = chalk.bold.hex('#4A90E2')('│');
|
|
|
|
|
|
|
|
|
|
// Header
|
|
|
|
|
const title = ' Add Time ';
|
|
|
|
|
const leftLen = stripAnsi(title).length;
|
|
|
|
|
let leftHeader = chalk.bold.hex('#4A90E2')(title);
|
|
|
|
|
|
|
|
|
|
console.log(leftHeader + ' '.repeat(Math.max(1, cols - leftLen - 1)));
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
|
|
|
|
|
|
|
|
|
|
if (this.state.error) {
|
|
|
|
|
console.log(borderCh + chalk.red(` Error: ${this.state.error}`).padEnd(cols - 2) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const form = this.state.addTimeForm;
|
|
|
|
|
|
|
|
|
|
// Time Field
|
|
|
|
|
const timeLabel = chalk.yellow.bold(' Time Spent (e.g. 1h 30m, 45m): ');
|
|
|
|
|
console.log(borderCh + timeLabel + ' '.repeat(Math.max(0, cols - stripAnsi(timeLabel).length - 2)) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
|
|
|
|
|
// Input Box
|
|
|
|
|
let visLine = form.timeInput;
|
|
|
|
|
if (visLine.length > cols - 5) {
|
|
|
|
|
visLine = visLine.substring(visLine.length - (cols - 5));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
visLine = visLine + chalk.inverse(' ') + ' '.repeat(Math.max(0, cols - 5 - stripAnsi(visLine).length));
|
|
|
|
|
|
|
|
|
|
console.log(borderCh + ' ' + visLine + ' ' + borderCh);
|
|
|
|
|
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
|
|
|
|
|
|
|
|
|
|
// Help line
|
|
|
|
|
const helpLine = chalk.gray(' [Ctrl+S] Submit [Esc] Cancel');
|
|
|
|
|
const issueStr = this.state.selectedIssue ? chalk.bold.cyan(` Issue #${this.state.selectedIssue.number} `) : '';
|
|
|
|
|
const issueLen = stripAnsi(issueStr).length;
|
|
|
|
|
|
|
|
|
|
const maxHelpLen = cols - issueLen - 1;
|
|
|
|
|
let helpLinePlain = helpLine;
|
|
|
|
|
if (stripAnsi(helpLinePlain).length > maxHelpLen) {
|
|
|
|
|
helpLinePlain = chalk.gray(stripAnsi(helpLinePlain).substring(0, maxHelpLen - 3) + '...');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const spaces = cols - stripAnsi(helpLinePlain).length - issueLen - 1;
|
|
|
|
|
process.stdout.write(helpLinePlain + ' '.repeat(Math.max(0, spaces)) + issueStr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleAddTimeKeypress(str: string, key: any) {
|
|
|
|
|
if (key && key.name === 'escape') {
|
|
|
|
|
this.state.screen = 'details';
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.ctrl && key.name === 's') {
|
|
|
|
|
const input = this.state.addTimeForm.timeInput.trim();
|
|
|
|
|
if (!input) {
|
|
|
|
|
this.state.error = 'Time input cannot be empty.';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.state.selectedIssue) {
|
|
|
|
|
this.state.error = 'No issue selected.';
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse time input (e.g. 1h 30m, 45m, 2h)
|
|
|
|
|
let totalSeconds = 0;
|
|
|
|
|
const hMatch = input.match(/(\d+)\s*h/);
|
|
|
|
|
const mMatch = input.match(/(\d+)\s*m/);
|
|
|
|
|
|
|
|
|
|
if (hMatch) {
|
|
|
|
|
totalSeconds += parseInt(hMatch[1], 10) * 3600;
|
|
|
|
|
}
|
|
|
|
|
if (mMatch) {
|
|
|
|
|
totalSeconds += parseInt(mMatch[1], 10) * 60;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If neither h nor m matched, but it's just a number, assume minutes
|
|
|
|
|
if (!hMatch && !mMatch && /^\d+$/.test(input)) {
|
|
|
|
|
totalSeconds += parseInt(input, 10) * 60;
|
|
|
|
|
} else if (!hMatch && !mMatch) {
|
|
|
|
|
this.state.error = "Invalid format. Use '1h 30m', '45m', or just minutes like '45'.";
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (totalSeconds <= 0) {
|
|
|
|
|
this.state.error = "Time must be greater than 0.";
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.state.loading = true;
|
|
|
|
|
this.render();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await addIssueTime(this.state.config, this.state.selectedIssue.number, totalSeconds);
|
|
|
|
|
this.state.screen = 'details';
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
// Reload to show the time tracked
|
|
|
|
|
this.reloadSingleIssue();
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
this.state.error = err.message;
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let val = this.state.addTimeForm.timeInput;
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'backspace') {
|
|
|
|
|
if (val.length > 0) {
|
|
|
|
|
this.state.addTimeForm.timeInput = val.slice(0, -1);
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str && str.length === 1 && !key.ctrl && !key.meta && str !== '\n' && str !== '\r') {
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.state.addTimeForm.timeInput += str;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-02 00:15:30 +00:00
|
|
|
|
|
|
|
|
private renderConfirmStateChangeScreen(cols: number, rows: number) {
|
|
|
|
|
const borderCh = chalk.bold.hex('#4A90E2')('│');
|
|
|
|
|
|
|
|
|
|
const issue = this.state.selectedIssue;
|
|
|
|
|
if (!issue) return;
|
|
|
|
|
|
|
|
|
|
const action = issue.state === 'open' ? 'Close' : 'Reopen';
|
|
|
|
|
const title = ` Confirm ${action} `;
|
|
|
|
|
const leftLen = stripAnsi(title).length;
|
|
|
|
|
let leftHeader = chalk.bold.hex('#4A90E2')(title);
|
|
|
|
|
|
|
|
|
|
console.log(leftHeader + ' '.repeat(Math.max(1, cols - leftLen - 1)));
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
|
|
|
|
|
|
|
|
|
|
if (this.state.error) {
|
|
|
|
|
console.log(borderCh + chalk.red(` Error: ${this.state.error}`).padEnd(cols - 2) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const question = `Are you sure you want to ${action.toLowerCase()} issue #${issue.number}?`;
|
|
|
|
|
const promptPlain = ` ${question} [y/N] `;
|
|
|
|
|
|
|
|
|
|
const paddingRows = Math.floor((rows - 8) / 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 - promptPlain.length) / 2))) + chalk.yellow.bold(question) + ' [y/N] ').padEnd(cols - 2 + (chalk.yellow.bold(question).length - question.length));
|
|
|
|
|
console.log(borderCh + contentLine + borderCh);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < (rows - 8) - paddingRows; i++) console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
|
|
|
|
|
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
|
|
|
|
|
|
|
|
|
|
const helpLinePlain = ' [y] Yes [n/Esc] No';
|
|
|
|
|
process.stdout.write(chalk.gray(helpLinePlain));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleConfirmStateChangeKeypress(str: string, key: any) {
|
|
|
|
|
if ((key && (key.name === 'escape' || key.name === 'n')) || str === 'n' || str === 'N') {
|
|
|
|
|
this.state.screen = 'details';
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str === 'y' || str === 'Y') {
|
|
|
|
|
const issue = this.state.selectedIssue;
|
|
|
|
|
if (!issue) return;
|
|
|
|
|
|
|
|
|
|
this.state.error = null;
|
2026-06-02 00:36:39 +00:00
|
|
|
const newState = issue.state === 'open' ? 'closed' : 'open';
|
|
|
|
|
this.state.screen = newState === 'closed' ? 'animating-close' : 'animating-reopen';
|
|
|
|
|
this.animationFrame = 0;
|
2026-06-02 00:15:30 +00:00
|
|
|
this.render();
|
|
|
|
|
|
2026-06-02 00:36:39 +00:00
|
|
|
if (this.animationInterval) {
|
|
|
|
|
clearInterval(this.animationInterval);
|
2026-06-02 00:15:30 +00:00
|
|
|
}
|
2026-06-02 00:36:39 +00:00
|
|
|
|
|
|
|
|
this.animationInterval = setInterval(() => {
|
|
|
|
|
this.animationFrame++;
|
|
|
|
|
const frameCount = newState === 'closed' ? GRAVESTONE_FRAMES.length : ZOMBIE_FRAMES.length;
|
|
|
|
|
this.render();
|
|
|
|
|
|
|
|
|
|
if (this.animationFrame >= frameCount - 1) {
|
|
|
|
|
if (this.animationInterval) {
|
|
|
|
|
clearInterval(this.animationInterval);
|
|
|
|
|
this.animationInterval = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTimeout(async () => {
|
|
|
|
|
this.state.loading = true;
|
|
|
|
|
this.render();
|
|
|
|
|
try {
|
|
|
|
|
await changeIssueState(this.state.config, issue.number, newState);
|
|
|
|
|
this.state.screen = 'details';
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
this.reloadSingleIssue();
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
this.state.error = err.message;
|
|
|
|
|
this.state.screen = 'details';
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
}, 300);
|
|
|
|
|
}
|
|
|
|
|
}, 300);
|
|
|
|
|
|
2026-06-02 00:15:30 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-02 18:41:33 +00:00
|
|
|
private renderSetAssigneesScreen(cols: number, rows: number) {
|
|
|
|
|
const borderCh = chalk.bold.hex('#4A90E2')('│');
|
|
|
|
|
|
|
|
|
|
const title = ' Set Assignees ';
|
|
|
|
|
const leftLen = stripAnsi(title).length;
|
|
|
|
|
let leftHeader = chalk.bold.hex('#4A90E2')(title);
|
|
|
|
|
|
|
|
|
|
console.log(leftHeader + ' '.repeat(Math.max(1, cols - leftLen - 1)));
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
|
|
|
|
|
|
|
|
|
|
if (this.state.error) {
|
|
|
|
|
console.log(borderCh + chalk.red(` Error: ${this.state.error}`).padEnd(cols - 2) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const label = chalk.yellow(' Assignees (comma-separated usernames): ');
|
|
|
|
|
console.log(borderCh + label + ' '.repeat(cols - stripAnsi(label).length - 2) + borderCh);
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
|
|
|
|
|
|
|
|
|
const inputVal = this.state.assigneesInput;
|
|
|
|
|
let visVal = inputVal;
|
|
|
|
|
if (visVal.length > cols - 5) {
|
|
|
|
|
visVal = visVal.substring(visVal.length - (cols - 5));
|
|
|
|
|
}
|
|
|
|
|
visVal = visVal + chalk.inverse(' ') + ' '.repeat(Math.max(0, cols - 5 - stripAnsi(visVal).length));
|
|
|
|
|
console.log(borderCh + ' ' + visVal + ' ' + borderCh);
|
|
|
|
|
|
|
|
|
|
const fillRows = rows - (this.state.error ? 8 : 6) - 1;
|
|
|
|
|
for (let i = 0; i < fillRows; i++) {
|
|
|
|
|
console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
|
2026-06-01 02:02:23 +00:00
|
|
|
|
2026-06-02 18:41:33 +00:00
|
|
|
const helpLine = chalk.gray(' [Enter] Submit [Esc] Cancel (leave blank to clear assignees)');
|
|
|
|
|
const issueStr = this.state.selectedIssue ? chalk.bold.cyan(` Issue #${this.state.selectedIssue.number} `) : '';
|
|
|
|
|
const spaces = cols - stripAnsi(helpLine).length - stripAnsi(issueStr).length;
|
|
|
|
|
process.stdout.write(helpLine + ' '.repeat(Math.max(0, spaces)) + issueStr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleSetAssigneesKeypress(str: string, key: any) {
|
|
|
|
|
if (key && key.name === 'escape') {
|
|
|
|
|
this.state.screen = 'details';
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'backspace') {
|
|
|
|
|
if (this.state.assigneesInput.length > 0) {
|
|
|
|
|
this.state.assigneesInput = this.state.assigneesInput.slice(0, -1);
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (key && key.name === 'return') {
|
|
|
|
|
const issue = this.state.selectedIssue;
|
|
|
|
|
if (!issue) return;
|
|
|
|
|
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.state.loading = true;
|
|
|
|
|
this.render();
|
|
|
|
|
|
|
|
|
|
const assignees = this.state.assigneesInput
|
|
|
|
|
.split(',')
|
|
|
|
|
.map(s => s.trim())
|
|
|
|
|
.filter(s => s.length > 0);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await setIssueAssignees(this.state.config, issue.number, assignees);
|
|
|
|
|
this.state.screen = 'details';
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
this.reloadSingleIssue();
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
this.state.error = err.message;
|
|
|
|
|
this.state.loading = false;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str && str.length === 1 && !key.ctrl && !key.meta) {
|
|
|
|
|
this.state.error = null;
|
|
|
|
|
this.state.assigneesInput += str;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|