4044 lines
124 KiB
TypeScript
4044 lines
124 KiB
TypeScript
import readline from "readline";
|
|
import chalk from "chalk";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
import { AppState, Issue, Comment } from "./types.js";
|
|
import {
|
|
fetchIssues,
|
|
fetchIssue,
|
|
fetchIssueComments,
|
|
validateConnection,
|
|
normalizeUrl,
|
|
authenticateAndFetchRepos,
|
|
createIssue,
|
|
createIssueComment,
|
|
editIssue,
|
|
addIssueTime,
|
|
changeIssueState,
|
|
setIssueAssignees,
|
|
fetchLabels,
|
|
createLabel,
|
|
updateLabel,
|
|
deleteLabel,
|
|
} from "./api.js";
|
|
import { saveGlobalConfig } from "./config.js";
|
|
|
|
// Setup readline for stdin keypress events
|
|
readline.emitKeypressEvents(process.stdin);
|
|
|
|
// Spinner chars for loading animations
|
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
let spinnerIndex = 0;
|
|
|
|
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(" __|_____|__ "),
|
|
],
|
|
];
|
|
|
|
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";
|
|
}
|
|
}
|
|
|
|
// Utility to safely truncate strings
|
|
function truncate(str: string, length: number): string {
|
|
if (str.length <= length) return str;
|
|
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`;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
"",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* TUI State Controller and Render Engine
|
|
*/
|
|
export class TuiEngine {
|
|
private state: AppState;
|
|
private onQuit: () => void = () => {};
|
|
private activeSearchInput: boolean = false;
|
|
private searchInputBuffer: string = "";
|
|
private animationFrame: number = 0;
|
|
private animationInterval: NodeJS.Timeout | null = null;
|
|
private launchDestScreen: "list" | "setup" = "setup";
|
|
private launchFrame: string[] = [];
|
|
private launchFrameIndex = 0;
|
|
private launchInterval: NodeJS.Timeout | null = null;
|
|
private maxLaunchFrames = 60;
|
|
private autoRefreshTimer: NodeJS.Timeout | null = null;
|
|
|
|
private restartAutoRefresh() {
|
|
if (this.autoRefreshTimer) {
|
|
clearInterval(this.autoRefreshTimer);
|
|
this.autoRefreshTimer = null;
|
|
}
|
|
|
|
if (this.state.autoRefreshInterval && this.state.autoRefreshInterval > 0) {
|
|
this.autoRefreshTimer = setInterval(() => {
|
|
// We need a way to check if input is active, but isTextInputActive isn't accessible here if we didn't add it.
|
|
// Wait, isTextInputActive is a method of TuiEngine, or I reverted it?
|
|
// Ah! I reverted isTextInputActive earlier!
|
|
const isTextInputActive =
|
|
this.state.screen === "setup" ||
|
|
(this.state.screen === "repo-picker" &&
|
|
this.state.repoPickerActiveSearch) ||
|
|
(this.state.screen === "list" && this.activeSearchInput) ||
|
|
this.state.screen === "create-issue" ||
|
|
this.state.screen === "add-comment" ||
|
|
this.state.screen === "edit-issue" ||
|
|
this.state.screen === "add-time" ||
|
|
this.state.screen === "set-assignees" ||
|
|
this.state.screen === "create-label" ||
|
|
this.state.screen === "edit-label";
|
|
|
|
if (isTextInputActive) return;
|
|
|
|
// Background refresh
|
|
if (this.state.screen === "list") {
|
|
this.loadIssues();
|
|
} else if (
|
|
this.state.screen === "details" &&
|
|
this.state.selectedIssue
|
|
) {
|
|
this.reloadSingleIssue();
|
|
}
|
|
}, this.state.autoRefreshInterval * 1000);
|
|
}
|
|
}
|
|
|
|
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));
|
|
|
|
// Enter alternate screen buffer and hide standard cursor
|
|
process.stdout.write("\x1B[?1049h\x1B[?25l");
|
|
|
|
// Bootstrap destination
|
|
if (
|
|
this.state.config.url &&
|
|
this.state.config.owner &&
|
|
this.state.config.repo
|
|
) {
|
|
this.launchDestScreen = "list";
|
|
} else {
|
|
this.launchDestScreen = "setup";
|
|
}
|
|
|
|
this.restartAutoRefresh();
|
|
this.state.screen = "launch";
|
|
this.startLaunchAnimation();
|
|
}
|
|
|
|
/**
|
|
* Stops the TUI engine, restores terminal state.
|
|
*/
|
|
public stop() {
|
|
this.stopSpinner();
|
|
if (this.animationInterval) {
|
|
clearInterval(this.animationInterval);
|
|
this.animationInterval = null;
|
|
}
|
|
if (this.launchInterval) {
|
|
clearInterval(this.launchInterval);
|
|
this.launchInterval = null;
|
|
}
|
|
if (this.autoRefreshTimer) {
|
|
clearInterval(this.autoRefreshTimer);
|
|
this.autoRefreshTimer = null;
|
|
}
|
|
// Exit alternate screen buffer and show standard cursor
|
|
process.stdout.write("\x1B[?1049l\x1B[?25h");
|
|
if (process.stdin.isTTY) {
|
|
process.stdin.setRawMode(false);
|
|
}
|
|
process.stdin.pause();
|
|
this.onQuit();
|
|
}
|
|
|
|
private startSpinner() {
|
|
this.stopSpinner();
|
|
spinnerInterval = setInterval(() => {
|
|
spinnerIndex = (spinnerIndex + 1) % SPINNER_FRAMES.length;
|
|
this.render();
|
|
}, 80);
|
|
}
|
|
|
|
private stopSpinner() {
|
|
if (spinnerInterval) {
|
|
clearInterval(spinnerInterval);
|
|
spinnerInterval = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches issues from the API client based on current state parameters.
|
|
*/
|
|
private async loadIssues() {
|
|
this.state.loading = true;
|
|
this.state.error = null;
|
|
this.startSpinner();
|
|
|
|
try {
|
|
const { issues, totalCount } = await fetchIssues(this.state.config, {
|
|
page: this.state.currentPage,
|
|
limit: this.state.issuesPerPage,
|
|
state: this.state.stateFilter,
|
|
type: this.state.typeFilter,
|
|
q: this.state.searchQuery,
|
|
sortField: this.state.sortField,
|
|
sortOrder: this.state.sortOrder,
|
|
});
|
|
|
|
this.state.issues = issues;
|
|
this.state.totalIssuesCount = totalCount;
|
|
|
|
// Keep cursor bounds-checked
|
|
if (this.state.selectedIssueIndex >= issues.length) {
|
|
this.state.selectedIssueIndex = Math.max(0, issues.length - 1);
|
|
}
|
|
} catch (err: any) {
|
|
this.state.error = err.message;
|
|
this.state.issues = [];
|
|
this.state.totalIssuesCount = 0;
|
|
} finally {
|
|
this.state.loading = false;
|
|
this.stopSpinner();
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches 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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 === "launch") {
|
|
this.skipLaunchAnimation();
|
|
return;
|
|
}
|
|
|
|
if (this.state.screen === "setup") {
|
|
this.handleSetupKeypress(str, key);
|
|
} else if (this.state.screen === "repo-picker") {
|
|
this.handleRepoPickerKeypress(str, key);
|
|
} else if (this.state.screen === "list") {
|
|
if (this.activeSearchInput) {
|
|
this.handleSearchKeypress(str, key);
|
|
} else {
|
|
this.handleListKeypress(str, key);
|
|
}
|
|
} else if (this.state.screen === "details") {
|
|
this.handleDetailsKeypress(str, key);
|
|
} else if (this.state.screen === "create-issue") {
|
|
this.handleCreateIssueKeypress(str, key);
|
|
} else if (this.state.screen === "confirm-cancel-create") {
|
|
this.handleConfirmCancelCreateKeypress(str, key);
|
|
} else if (this.state.screen === "confirm-cancel-edit") {
|
|
this.handleConfirmCancelEditKeypress(str, key);
|
|
} else if (this.state.screen === "add-comment") {
|
|
this.handleAddCommentKeypress(str, key);
|
|
} else if (this.state.screen === "edit-issue") {
|
|
this.handleEditIssueKeypress(str, key);
|
|
} else if (this.state.screen === "add-time") {
|
|
this.handleAddTimeKeypress(str, key);
|
|
} else if (this.state.screen === "set-assignees") {
|
|
this.handleSetAssigneesKeypress(str, key);
|
|
} else if (this.state.screen === "labels-list") {
|
|
this.handleLabelsListKeypress(str, key);
|
|
} else if (
|
|
this.state.screen === "create-label" ||
|
|
this.state.screen === "edit-label"
|
|
) {
|
|
this.handleLabelFormKeypress(str, key);
|
|
} else if (this.state.screen === "help") {
|
|
this.handleHelpKeypress(str, key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Key handling for Help screen
|
|
*/
|
|
private handleHelpKeypress(str: string, key: any) {
|
|
if (
|
|
(key && (key.name === "escape" || key.name === "return")) ||
|
|
str === "q" ||
|
|
str === "Q" ||
|
|
str === "?"
|
|
) {
|
|
this.state.screen = this.state.previousScreen || "list";
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Key handling for Setup form
|
|
*/
|
|
private async handleSetupKeypress(str: string, key: any) {
|
|
const form = this.state.setupForm;
|
|
const fields: Array<"url" | "userid" | "token" | "saveConfig"> = [
|
|
"url",
|
|
"userid",
|
|
"token",
|
|
"saveConfig",
|
|
];
|
|
const currentIdx = fields.indexOf(form.activeField);
|
|
|
|
if (key && key.name === "down") {
|
|
form.activeField = fields[(currentIdx + 1) % fields.length];
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (key && key.name === "up") {
|
|
form.activeField =
|
|
fields[(currentIdx - 1 + fields.length) % fields.length];
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (key && key.name === "tab") {
|
|
form.activeField = fields[(currentIdx + 1) % fields.length];
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (str === " ") {
|
|
if (form.activeField === "saveConfig") {
|
|
form.saveConfig = !form.saveConfig;
|
|
this.render();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if ((key && key.name === "return") || str === "\r" || str === "\n") {
|
|
if (form.activeField === "saveConfig") {
|
|
form.saveConfig = !form.saveConfig;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
// Submit form
|
|
if (!form.url.trim()) {
|
|
this.state.error = "Instance URL is required.";
|
|
this.render();
|
|
return;
|
|
}
|
|
if (!form.userid.trim()) {
|
|
this.state.error = "User ID / Username is required.";
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
this.state.loading = true;
|
|
this.state.error = null;
|
|
this.render();
|
|
|
|
try {
|
|
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();
|
|
} catch (err: any) {
|
|
this.state.error = err.message;
|
|
this.state.loading = false;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ((key && key.name === "escape") || str === "\u001b") {
|
|
this.stop();
|
|
return;
|
|
}
|
|
|
|
if (key && key.name === "backspace") {
|
|
if (form.activeField === "url") form.url = form.url.slice(0, -1);
|
|
if (form.activeField === "userid") form.userid = form.userid.slice(0, -1);
|
|
if (form.activeField === "token") form.token = form.token.slice(0, -1);
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
// Type character
|
|
if (
|
|
str &&
|
|
!key.ctrl &&
|
|
!key.meta &&
|
|
str.length === 1 &&
|
|
str.charCodeAt(0) >= 32
|
|
) {
|
|
if (form.activeField === "url") form.url += str;
|
|
if (form.activeField === "userid") form.userid += str;
|
|
if (form.activeField === "token") form.token += str;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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") || str === "k") {
|
|
if (this.state.selectedRepoIndex > 0) {
|
|
this.state.selectedRepoIndex--;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ((key && key.name === "down") || str === "j") {
|
|
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],
|
|
};
|
|
|
|
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]}`,
|
|
});
|
|
}
|
|
|
|
this.state.screen = "list";
|
|
this.state.currentPage = 1;
|
|
this.state.selectedIssueIndex = 0;
|
|
this.state.searchQuery = "";
|
|
this.loadIssues();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 === "tab") {
|
|
this.state.focusedPane =
|
|
this.state.focusedPane === "settings" ? "list" : "settings";
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (this.state.focusedPane === "settings") {
|
|
if (key && key.name === "left") {
|
|
this.state.selectedSettingIndex =
|
|
(this.state.selectedSettingIndex - 1 + 5) % 5;
|
|
this.render();
|
|
return;
|
|
}
|
|
if (key && key.name === "right") {
|
|
this.state.selectedSettingIndex =
|
|
(this.state.selectedSettingIndex + 1) % 5;
|
|
this.render();
|
|
return;
|
|
}
|
|
if (key && key.name === "up") {
|
|
return;
|
|
}
|
|
if (key && key.name === "down") {
|
|
return;
|
|
}
|
|
if ((key && key.name === "return") || str === "\r" || str === "\n") {
|
|
const selIdx = this.state.selectedSettingIndex;
|
|
if (selIdx === 0) {
|
|
// Search
|
|
this.activeSearchInput = true;
|
|
this.searchInputBuffer = this.state.searchQuery;
|
|
this.render();
|
|
} else if (selIdx === 1) {
|
|
// State
|
|
const states: Array<"open" | "closed" | "all"> = [
|
|
"open",
|
|
"closed",
|
|
"all",
|
|
];
|
|
const currentIdx = states.indexOf(this.state.stateFilter);
|
|
this.state.stateFilter = states[(currentIdx + 1) % states.length];
|
|
this.state.currentPage = 1;
|
|
this.state.selectedIssueIndex = 0;
|
|
this.loadIssues();
|
|
} else if (selIdx === 2) {
|
|
// Type
|
|
const types: Array<"issues" | "pulls" | "all"> = [
|
|
"issues",
|
|
"pulls",
|
|
"all",
|
|
];
|
|
const currentIdx = types.indexOf(this.state.typeFilter);
|
|
this.state.typeFilter = types[(currentIdx + 1) % types.length];
|
|
this.state.currentPage = 1;
|
|
this.state.selectedIssueIndex = 0;
|
|
this.loadIssues();
|
|
} else if (selIdx === 3) {
|
|
// Sort
|
|
const fields: Array<
|
|
"created" | "updated" | "comments" | "assignees"
|
|
> = ["created", "updated", "comments", "assignees"];
|
|
const currentSortIdx = fields.indexOf(this.state.sortField);
|
|
if (this.state.sortOrder === "desc") {
|
|
this.state.sortOrder = "asc";
|
|
} else {
|
|
this.state.sortOrder = "desc";
|
|
this.state.sortField = fields[(currentSortIdx + 1) % fields.length];
|
|
}
|
|
this.state.currentPage = 1;
|
|
this.state.selectedIssueIndex = 0;
|
|
this.loadIssues();
|
|
} else if (selIdx === 4) {
|
|
// Auto-Refresh
|
|
const intervals = [0, 15, 30, 60, 300];
|
|
const currentIdx = intervals.indexOf(
|
|
this.state.autoRefreshInterval || 0,
|
|
);
|
|
this.state.autoRefreshInterval =
|
|
intervals[(currentIdx + 1) % intervals.length];
|
|
this.state.config.autoRefreshInterval =
|
|
this.state.autoRefreshInterval;
|
|
// Restart timer
|
|
this.restartAutoRefresh();
|
|
// Save to config globally
|
|
saveGlobalConfig(this.state.config);
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (str === "?") {
|
|
this.state.previousScreen = "list";
|
|
this.state.screen = "help";
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if ((key && key.name === "escape") || str === "\u001b") {
|
|
this.stop();
|
|
return;
|
|
}
|
|
|
|
if (str === "o" || str === "O") {
|
|
this.state.screen = "setup";
|
|
this.state.error = null;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if ((key && key.name === "up") || str === "k") {
|
|
if (this.state.selectedIssueIndex > 0) {
|
|
this.state.selectedIssueIndex--;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ((key && key.name === "down") || str === "j") {
|
|
if (this.state.selectedIssueIndex < this.state.issues.length - 1) {
|
|
this.state.selectedIssueIndex++;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Pagination: Right/PageDown -> Next, Left/PageUp -> Prev
|
|
if (
|
|
(key && key.name === "right") ||
|
|
(key && key.name === "pagedown") ||
|
|
str === "n"
|
|
) {
|
|
const maxPages = Math.ceil(
|
|
this.state.totalIssuesCount / this.state.issuesPerPage,
|
|
);
|
|
if (this.state.currentPage < maxPages) {
|
|
this.state.currentPage++;
|
|
this.state.selectedIssueIndex = 0;
|
|
this.loadIssues();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(key && key.name === "left") ||
|
|
(key && key.name === "pageup") ||
|
|
str === "p"
|
|
) {
|
|
if (this.state.currentPage > 1) {
|
|
this.state.currentPage--;
|
|
this.state.selectedIssueIndex = 0;
|
|
this.loadIssues();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ((key && key.name === "return") || str === "\r" || str === "\n") {
|
|
if (this.state.issues.length > 0) {
|
|
const issue = this.state.issues[this.state.selectedIssueIndex];
|
|
this.state.selectedIssue = issue;
|
|
this.state.detailScrollOffset = 0;
|
|
this.state.screen = "details";
|
|
this.loadComments(issue);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Toggle Filters & Sorting
|
|
if (str === "/") {
|
|
// Focus Search input
|
|
this.activeSearchInput = true;
|
|
this.searchInputBuffer = this.state.searchQuery;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (str === "s" || str === "S") {
|
|
// Cycle sorts: created -> updated -> comments -> assignees
|
|
const fields: Array<"created" | "updated" | "comments" | "assignees"> = [
|
|
"created",
|
|
"updated",
|
|
"comments",
|
|
"assignees",
|
|
];
|
|
const currentSortIdx = fields.indexOf(this.state.sortField);
|
|
|
|
// If desc, switch to asc. If asc, switch to next field desc!
|
|
if (this.state.sortOrder === "desc") {
|
|
this.state.sortOrder = "asc";
|
|
} else {
|
|
this.state.sortOrder = "desc";
|
|
this.state.sortField = fields[(currentSortIdx + 1) % fields.length];
|
|
}
|
|
this.state.currentPage = 1;
|
|
this.state.selectedIssueIndex = 0;
|
|
this.loadIssues();
|
|
return;
|
|
}
|
|
|
|
if (str === "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.state.createIssueForm.cursor =
|
|
this.state.createIssueForm.title.length;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (str === "f" || str === "F") {
|
|
// Cycle states: open -> closed -> all
|
|
const states: Array<"open" | "closed" | "all"> = [
|
|
"open",
|
|
"closed",
|
|
"all",
|
|
];
|
|
const currentIdx = states.indexOf(this.state.stateFilter);
|
|
this.state.stateFilter = states[(currentIdx + 1) % states.length];
|
|
this.state.currentPage = 1;
|
|
this.state.selectedIssueIndex = 0;
|
|
this.loadIssues();
|
|
return;
|
|
}
|
|
|
|
if (str === "t" || str === "T") {
|
|
// Cycle type: issues -> pulls -> all
|
|
const types: Array<"issues" | "pulls" | "all"> = [
|
|
"issues",
|
|
"pulls",
|
|
"all",
|
|
];
|
|
const currentIdx = types.indexOf(this.state.typeFilter);
|
|
this.state.typeFilter = types[(currentIdx + 1) % types.length];
|
|
this.state.currentPage = 1;
|
|
this.state.selectedIssueIndex = 0;
|
|
this.loadIssues();
|
|
return;
|
|
}
|
|
|
|
if (str === "r" || str === "R") {
|
|
// Refresh
|
|
this.loadIssues();
|
|
return;
|
|
}
|
|
|
|
if (str === "l" || str === "L") {
|
|
this.state.screen = "labels-list";
|
|
this.state.selectedLabelIndex = 0;
|
|
this.loadLabels();
|
|
return;
|
|
}
|
|
|
|
if (str === "q" || str === "Q") {
|
|
this.stop();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Key handling for detail screen scrolling
|
|
*/
|
|
private handleDetailsKeypress(str: string, key: any) {
|
|
if (key && key.name === "tab") {
|
|
this.state.focusedPane =
|
|
this.state.focusedPane === "settings" ? "list" : "settings";
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (this.state.focusedPane === "settings") {
|
|
if ((key && key.name === "return") || str === "\r" || str === "\n") {
|
|
const intervals = [0, 15, 30, 60, 300];
|
|
const currentIdx = intervals.indexOf(
|
|
this.state.autoRefreshInterval || 0,
|
|
);
|
|
this.state.autoRefreshInterval =
|
|
intervals[(currentIdx + 1) % intervals.length];
|
|
this.state.config.autoRefreshInterval = this.state.autoRefreshInterval;
|
|
this.restartAutoRefresh();
|
|
saveGlobalConfig(this.state.config);
|
|
this.render();
|
|
return;
|
|
}
|
|
if ((key && key.name === "escape") || str === "\u001b") {
|
|
this.state.focusedPane = "list";
|
|
this.render();
|
|
return;
|
|
}
|
|
// Swallow other keys in settings mode
|
|
return;
|
|
}
|
|
|
|
if ((key && key.name === "up") || str === "k") {
|
|
if (this.state.detailScrollOffset > 0) {
|
|
this.state.detailScrollOffset--;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Navigate with j/k as well for vim-style navigation in details view content scroll
|
|
else if ((key && key.name === "down") || str === "j") {
|
|
this.state.detailScrollOffset++;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (key && key.name === "pagedown") {
|
|
this.state.detailScrollOffset += 10;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (str === "o" || str === "O") {
|
|
this.state.screen = "setup";
|
|
this.state.selectedIssue = null;
|
|
this.state.selectedIssueComments = [];
|
|
this.state.error = null;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (str === "c" || str === "C") {
|
|
this.state.screen = "add-comment";
|
|
this.state.addCommentForm.body = "";
|
|
this.state.error = null;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
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.editIssueForm.cursor = this.state.editIssueForm.title.length;
|
|
this.state.error = null;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (str === "r" || str === "R") {
|
|
if (this.state.selectedIssue) {
|
|
this.reloadSingleIssue();
|
|
}
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (str === "x" || str === "X") {
|
|
if (this.state.selectedIssue) {
|
|
this.state.screen = "confirm-state-change";
|
|
this.state.error = null;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (str === "?") {
|
|
this.state.previousScreen = "details";
|
|
this.state.screen = "help";
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(key && (key.name === "escape" || key.name === "backspace")) ||
|
|
str === "\u001b" ||
|
|
str === "q" ||
|
|
str === "Q"
|
|
) {
|
|
this.state.screen = "list";
|
|
this.state.selectedIssue = null;
|
|
this.state.selectedIssueComments = [];
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders the current screen to stdout
|
|
*/
|
|
public render() {
|
|
const cols = process.stdout.columns || 80;
|
|
const rows = process.stdout.rows || 24;
|
|
|
|
// Clear terminal screen, clear scrollback buffer, and reset cursor position
|
|
process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
|
|
|
|
if (this.state.screen === "launch") {
|
|
this.renderLaunchScreen(cols, rows);
|
|
} else if (this.state.screen === "setup") {
|
|
this.renderSetupScreen(cols, rows);
|
|
} else if (this.state.screen === "repo-picker") {
|
|
this.renderRepoPickerScreen(cols, rows);
|
|
} else if (this.state.screen === "list") {
|
|
this.renderListScreen(cols, rows);
|
|
} else if (this.state.screen === "details") {
|
|
this.renderDetailsScreen(cols, rows);
|
|
} else if (this.state.screen === "create-issue") {
|
|
this.renderCreateIssueScreen(cols, rows);
|
|
} else if (this.state.screen === "add-comment") {
|
|
this.renderAddCommentScreen(cols, rows);
|
|
} else if (this.state.screen === "edit-issue") {
|
|
this.renderEditIssueScreen(cols, rows);
|
|
} else if (this.state.screen === "add-time") {
|
|
this.renderAddTimeScreen(cols, rows);
|
|
} else if (this.state.screen === "confirm-state-change") {
|
|
this.renderConfirmStateChangeScreen(cols, rows);
|
|
} else if (this.state.screen === "confirm-cancel-create") {
|
|
this.renderConfirmCancelCreateScreen(cols, rows);
|
|
} else if (this.state.screen === "confirm-cancel-edit") {
|
|
this.renderConfirmCancelEditScreen(cols, rows);
|
|
} 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);
|
|
} else if (this.state.screen === "set-assignees") {
|
|
this.renderSetAssigneesScreen(cols, rows);
|
|
} else if (this.state.screen === "labels-list") {
|
|
this.renderLabelsList(cols, rows);
|
|
} else if (
|
|
this.state.screen === "create-label" ||
|
|
this.state.screen === "edit-label"
|
|
) {
|
|
this.renderLabelForm(cols, rows);
|
|
} else if (this.state.screen === "help") {
|
|
this.renderHelpScreen(cols, rows);
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
/**
|
|
* 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 valStrLength = displayVal.length + (active ? 1 : 0);
|
|
const paddingInsideBracket = Math.max(0, fieldWidth - 19 - valStrLength);
|
|
|
|
let coloredValStr = displayVal;
|
|
if (active) {
|
|
coloredValStr =
|
|
chalk.bold.cyan(displayVal) +
|
|
chalk.inverse(" ") +
|
|
" ".repeat(paddingInsideBracket);
|
|
} else {
|
|
coloredValStr =
|
|
chalk.white(displayVal) + " ".repeat(paddingInsideBracket);
|
|
}
|
|
|
|
const plainContent =
|
|
label.padEnd(16) +
|
|
": [ " +
|
|
displayVal +
|
|
(active ? " " : "") +
|
|
" ".repeat(paddingInsideBracket) +
|
|
" ]";
|
|
const coloredContent =
|
|
(active
|
|
? chalk.bold.white(label.padEnd(16) + ": [ ")
|
|
: chalk.gray(label.padEnd(16) + ": [ ")) +
|
|
coloredValStr +
|
|
(active ? chalk.bold.white(" ]") : chalk.gray(" ]"));
|
|
|
|
const activeMarker = active ? chalk.cyan("▶ ") : " ";
|
|
const remainingPadding = Math.max(0, width - 6 - plainContent.length);
|
|
|
|
return (
|
|
border +
|
|
" " +
|
|
activeMarker +
|
|
coloredContent +
|
|
" ".repeat(remainingPadding) +
|
|
" " +
|
|
border
|
|
);
|
|
};
|
|
|
|
const renderCheckbox = (
|
|
label: string,
|
|
checked: boolean,
|
|
active: boolean,
|
|
) => {
|
|
const activeMarker = active ? chalk.cyan("▶ ") : " ";
|
|
const box = checked ? "[X]" : "[ ]";
|
|
const plainContent = label.padEnd(16) + ": " + box;
|
|
const coloredContent = active
|
|
? chalk.bold.white(label.padEnd(16) + ": ") + chalk.bold.cyan(box)
|
|
: chalk.gray(label.padEnd(16) + ": ") + chalk.gray(box);
|
|
|
|
const remainingPadding = Math.max(0, width - 6 - plainContent.length);
|
|
return (
|
|
border +
|
|
" " +
|
|
activeMarker +
|
|
coloredContent +
|
|
" ".repeat(remainingPadding) +
|
|
" " +
|
|
border
|
|
);
|
|
};
|
|
|
|
lines.push(renderField("Gitea URL", form.url, form.activeField === "url"));
|
|
lines.push(border + " ".repeat(width - 2) + border);
|
|
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,
|
|
);
|
|
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);
|
|
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);
|
|
|
|
// Error message
|
|
if (this.state.error) {
|
|
lines.push(chalk.bold.red("├" + "─".repeat(width - 2) + "┤"));
|
|
const errorWrapped = wordWrap(this.state.error, width - 4);
|
|
for (const errLine of errorWrapped) {
|
|
lines.push(
|
|
border +
|
|
" " +
|
|
chalk.bold.red(errLine.padEnd(width - 4)) +
|
|
" " +
|
|
border,
|
|
);
|
|
}
|
|
}
|
|
|
|
lines.push(chalk.bold.hex("#4A90E2")("└" + "─".repeat(width - 2) + "┘"));
|
|
|
|
// Loading / Footer indicator
|
|
const spinner = this.state.loading
|
|
? chalk.cyan(SPINNER_FRAMES[spinnerIndex])
|
|
: " ";
|
|
const prompt = this.state.loading
|
|
? " Connecting to instance..."
|
|
: " [Tab/Arrows] Navigate [Space/Enter] Toggle Checkbox [Enter] Connect [Esc] Quit";
|
|
|
|
// Draw centered on terminal
|
|
const vertPadding = Math.max(0, Math.floor((rows - lines.length - 2) / 2));
|
|
process.stdout.write("\n".repeat(vertPadding));
|
|
for (const line of lines) {
|
|
console.log(
|
|
" ".repeat(Math.max(0, Math.floor((cols - width) / 2))) + line,
|
|
);
|
|
}
|
|
|
|
const footerText =
|
|
" ".repeat(Math.max(0, Math.floor((cols - width) / 2))) +
|
|
spinner +
|
|
chalk.gray(prompt);
|
|
console.log("\n" + footerText);
|
|
|
|
// Cursor trick: if typing, place cursor at appropriate spot
|
|
if (!this.state.loading) {
|
|
// Find current active field offset row
|
|
let activeRowOffset = vertPadding + 6; // URL row
|
|
if (form.activeField === "userid") activeRowOffset = vertPadding + 8;
|
|
if (form.activeField === "token") activeRowOffset = vertPadding + 11;
|
|
if (form.activeField === "saveConfig") activeRowOffset = vertPadding + 14;
|
|
|
|
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;
|
|
}
|
|
|
|
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?:\/\//,
|
|
"",
|
|
);
|
|
const rightHeader = "";
|
|
const rightLen = 0;
|
|
|
|
// We want the total line length to fit exactly within 'cols'
|
|
const maxLeftWidth = cols - 1;
|
|
|
|
// 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);
|
|
|
|
const instanceColor = ` ─ ${chalk.bold.white(instanceName)}`;
|
|
const instancePlain = ` ─ ${instanceName}`;
|
|
|
|
let statsColor = "";
|
|
let statsPlain = "";
|
|
let shortStatsColor = "";
|
|
let shortStatsPlain = "";
|
|
|
|
if (this.state.totalIssuesCount > 0) {
|
|
const totalPages = Math.ceil(
|
|
this.state.totalIssuesCount / this.state.issuesPerPage,
|
|
);
|
|
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})`;
|
|
} else if (!this.state.loading) {
|
|
statsColor = " ─ (0 issues found)";
|
|
statsPlain = " ─ (0 issues found)";
|
|
|
|
shortStatsColor = " ─ (0)";
|
|
shortStatsPlain = " ─ (0)";
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
const leftLen = stripAnsi(leftHeader).length;
|
|
let spacesCount = cols - leftLen - rightLen;
|
|
if (spacesCount < 1) spacesCount = 1;
|
|
|
|
console.log(leftHeader + " ".repeat(spacesCount - 1) + rightHeader);
|
|
console.log(chalk.bold.hex("#4A90E2")("┌" + "─".repeat(cols - 2) + "┐"));
|
|
|
|
// Column widths
|
|
const idWidth = 6;
|
|
const typeWidth = 5;
|
|
const stateWidth = 7;
|
|
const authorWidth = 14;
|
|
const assigneesWidth = 14;
|
|
const labelsWidth = 16;
|
|
const createdWidth = 12;
|
|
const commentsWidth = 6;
|
|
const timeWidth = 7;
|
|
// Title takes all remaining space. There are 10 columns + 11 borders + 10 spaces = 21 extra chars
|
|
const titleWidth = Math.max(
|
|
10,
|
|
cols -
|
|
idWidth -
|
|
typeWidth -
|
|
stateWidth -
|
|
authorWidth -
|
|
assigneesWidth -
|
|
labelsWidth -
|
|
createdWidth -
|
|
commentsWidth -
|
|
timeWidth -
|
|
21,
|
|
);
|
|
|
|
// Render Table Header
|
|
const padHeader = (title: string, w: number) =>
|
|
chalk.bold.white(title.padEnd(w));
|
|
const borderCh = chalk.bold.hex("#4A90E2")("│");
|
|
|
|
console.log(
|
|
borderCh +
|
|
" " +
|
|
padHeader("ID", idWidth) +
|
|
borderCh +
|
|
" " +
|
|
padHeader("Type", typeWidth) +
|
|
borderCh +
|
|
" " +
|
|
padHeader("State", stateWidth) +
|
|
borderCh +
|
|
" " +
|
|
padHeader("Title", titleWidth) +
|
|
borderCh +
|
|
" " +
|
|
padHeader("Author", authorWidth) +
|
|
borderCh +
|
|
" " +
|
|
padHeader("Assignees", assigneesWidth) +
|
|
borderCh +
|
|
" " +
|
|
padHeader("Labels", labelsWidth) +
|
|
borderCh +
|
|
" " +
|
|
padHeader("Created", createdWidth) +
|
|
borderCh +
|
|
" " +
|
|
padHeader("Coms", commentsWidth) +
|
|
borderCh +
|
|
" " +
|
|
padHeader("Time", timeWidth) +
|
|
borderCh,
|
|
);
|
|
console.log(chalk.bold.hex("#4A90E2")("├" + "─".repeat(cols - 2) + "┤"));
|
|
|
|
// Table Content Height available (rows - 10 ensures total printed lines is rows - 2, preventing terminal scrolling)
|
|
const tableHeight = rows - 10;
|
|
|
|
if (this.state.issues.length === 0) {
|
|
const msg = this.state.loading
|
|
? "Fetching issues from server..."
|
|
: this.state.error
|
|
? `Error: ${this.state.error}`
|
|
: "No issues found.";
|
|
const paddingRows = Math.floor((tableHeight - 1) / 2);
|
|
for (let i = 0; i < paddingRows; i++)
|
|
console.log(borderCh + " ".repeat(cols - 2) + borderCh);
|
|
|
|
const contentLine = (
|
|
" ".repeat(Math.max(0, Math.floor((cols - 2 - msg.length) / 2))) + msg
|
|
).padEnd(cols - 2);
|
|
console.log(
|
|
borderCh +
|
|
(this.state.error
|
|
? chalk.bold.red(contentLine)
|
|
: chalk.gray(contentLine)) +
|
|
borderCh,
|
|
);
|
|
|
|
for (let i = 0; i < tableHeight - paddingRows - 1; i++)
|
|
console.log(borderCh + " ".repeat(cols - 2) + borderCh);
|
|
} else {
|
|
// Loop through issues on current page
|
|
for (let i = 0; i < tableHeight; i++) {
|
|
if (i < this.state.issues.length) {
|
|
const issue = this.state.issues[i];
|
|
const isSelected = i === this.state.selectedIssueIndex;
|
|
|
|
const idStr = `#${issue.number}`.padEnd(idWidth);
|
|
const typeStr = (issue.pull_request ? "PR" : "Issue").padEnd(
|
|
typeWidth,
|
|
);
|
|
|
|
let stateStr = issue.state.toUpperCase().padEnd(stateWidth);
|
|
if (issue.state === "open") {
|
|
stateStr = issue.pull_request
|
|
? chalk.bold.magenta(stateStr)
|
|
: chalk.bold.green(stateStr);
|
|
} else {
|
|
stateStr = chalk.bold.red(stateStr);
|
|
}
|
|
|
|
const titleStr = truncate(issue.title, titleWidth).padEnd(titleWidth);
|
|
const authorStr = truncate(issue.user.login, authorWidth).padEnd(
|
|
authorWidth,
|
|
);
|
|
const assigneesNames = (issue.assignees || [])
|
|
.map((u) => u.login)
|
|
.join(",");
|
|
const assigneesStr = truncate(assigneesNames, assigneesWidth).padEnd(
|
|
assigneesWidth,
|
|
);
|
|
let plainLen = 0;
|
|
let issueLabelsStr = "";
|
|
for (let idx = 0; idx < issue.labels.length; idx++) {
|
|
const l = issue.labels[idx];
|
|
let name = l.name;
|
|
if (name.length + 2 > labelsWidth) {
|
|
name = name.substring(0, labelsWidth - 3) + "…";
|
|
}
|
|
const lText = ` ${name} `;
|
|
if (plainLen + lText.length > labelsWidth) break;
|
|
issueLabelsStr += chalk.bgHex("#" + l.color).black(lText);
|
|
plainLen += lText.length;
|
|
if (idx < issue.labels.length - 1 && plainLen + 1 <= labelsWidth) {
|
|
issueLabelsStr += " ";
|
|
plainLen += 1;
|
|
} else if (idx < issue.labels.length - 1) {
|
|
break;
|
|
}
|
|
}
|
|
issueLabelsStr += " ".repeat(Math.max(0, labelsWidth - plainLen));
|
|
const createdStr = formatDate(issue.created_at)
|
|
.substring(0, 10)
|
|
.padEnd(createdWidth);
|
|
const commentsStr = String(issue.comments).padEnd(commentsWidth);
|
|
const timeStr = formatTime(issue.total_tracked_time).padEnd(
|
|
timeWidth,
|
|
);
|
|
|
|
let rowText =
|
|
" " +
|
|
idStr +
|
|
borderCh +
|
|
" " +
|
|
typeStr +
|
|
borderCh +
|
|
" " +
|
|
stateStr +
|
|
borderCh +
|
|
" " +
|
|
(issue.pull_request ? chalk.magenta(titleStr) : titleStr) +
|
|
borderCh +
|
|
" " +
|
|
authorStr +
|
|
borderCh +
|
|
" " +
|
|
assigneesStr +
|
|
borderCh +
|
|
" " +
|
|
issueLabelsStr +
|
|
borderCh +
|
|
" " +
|
|
createdStr +
|
|
borderCh +
|
|
" " +
|
|
commentsStr +
|
|
borderCh +
|
|
" " +
|
|
timeStr +
|
|
borderCh;
|
|
|
|
if (isSelected) {
|
|
if (this.state.focusedPane === "settings") {
|
|
rowText = chalk.bgHex("#1C2F4D").white(rowText);
|
|
} else {
|
|
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
|
|
const isSettings = this.state.focusedPane === "settings";
|
|
const selIdx = this.state.selectedSettingIndex ?? 0;
|
|
|
|
const buildSegment = (
|
|
idx: number,
|
|
label: string,
|
|
val: string,
|
|
formattedVal: string,
|
|
) => {
|
|
const text = ` ${label}: ${val} `;
|
|
if (isSettings && selIdx === idx) {
|
|
return chalk.bgHex("#2E4E7E").white.bold(text);
|
|
}
|
|
return chalk.gray(` ${label}: `) + formattedVal;
|
|
};
|
|
|
|
const searchPlain = this.activeSearchInput
|
|
? this.searchInputBuffer
|
|
: this.state.searchQuery || "none";
|
|
const searchLabel = this.activeSearchInput
|
|
? chalk.inverse(this.searchInputBuffer + " ")
|
|
: this.state.searchQuery
|
|
? chalk.yellow(`"${this.state.searchQuery}"`)
|
|
: chalk.gray("none");
|
|
|
|
const statePlain = this.state.stateFilter.toUpperCase();
|
|
const stateLabel = chalk.bold(statePlain);
|
|
|
|
const typePlain = this.state.typeFilter.toUpperCase();
|
|
const typeLabel = chalk.bold(typePlain);
|
|
|
|
const sortPlain = `${this.state.sortField} (${this.state.sortOrder.toUpperCase()})`;
|
|
const sortLabel = chalk.bold(sortPlain);
|
|
|
|
const seg0 = buildSegment(0, "Search", searchPlain, searchLabel);
|
|
const seg1 = buildSegment(1, "State", statePlain, stateLabel);
|
|
const seg2 = buildSegment(2, "Type", typePlain, typeLabel);
|
|
const seg3 = buildSegment(3, "Sort", sortPlain, sortLabel);
|
|
|
|
let refreshStr = "Off";
|
|
if (this.state.autoRefreshInterval) {
|
|
if (this.state.autoRefreshInterval >= 60) {
|
|
refreshStr = `${this.state.autoRefreshInterval / 60}m`;
|
|
} else {
|
|
refreshStr = `${this.state.autoRefreshInterval}s`;
|
|
}
|
|
}
|
|
const seg4 = buildSegment(4, "Refresh", refreshStr, chalk.bold(refreshStr));
|
|
|
|
const separator = chalk.bold.hex("#4A90E2")(" ─ ");
|
|
const filtersText = ` ${seg0}${separator}${seg1}${separator}${seg2}${separator}${seg3}${separator}${seg4}`;
|
|
|
|
const filtersPlainLen = stripAnsi(filtersText).length;
|
|
const padding = Math.max(0, cols - 2 - filtersPlainLen);
|
|
console.log(borderCh + filtersText + " ".repeat(padding) + borderCh);
|
|
console.log(chalk.bold.hex("#4A90E2")("└" + "─".repeat(cols - 2) + "┘"));
|
|
|
|
// Keyboard controls help line
|
|
let fullHelpLine =
|
|
" [?] Help [↑/↓] Navigate [Enter] View [C] Create [/] Search [S] Sort [F] State [T] Type [L] Labels [N/P] Page [R] Reload [O] Settings [Esc] Quit";
|
|
if (isSettings) {
|
|
fullHelpLine =
|
|
" [?] Help [Tab] Back to List [←/→] Select Setting [Enter] Select/Toggle [C] Create [R] Reload [Esc] Quit";
|
|
}
|
|
const repoStr = chalk.bold.cyan(
|
|
` repo: ${this.state.config.owner}/${this.state.config.repo} `,
|
|
);
|
|
const repoLen = stripAnsi(repoStr).length;
|
|
|
|
// 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,
|
|
);
|
|
|
|
// 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] ");
|
|
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);
|
|
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(" ");
|
|
const assigneesStr = (issue.assignees || []).map((u) => u.login).join(", ");
|
|
const assigneesDisp = assigneesStr
|
|
? chalk.magenta(assigneesStr)
|
|
: chalk.gray("none");
|
|
const timeStr = formatTime(issue.total_tracked_time);
|
|
|
|
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)}`;
|
|
const metaPlainLen = stripAnsi(metaLine).length;
|
|
const padding = Math.max(0, cols - 2 - metaPlainLen);
|
|
console.log(borderCh + metaLine + " ".repeat(padding) + borderCh);
|
|
|
|
if (labels) {
|
|
const labelsLine = ` Labels: ${labels}`;
|
|
const labelsPlainLen = stripAnsi(labelsLine).length;
|
|
const labelsPadding = Math.max(0, cols - 2 - labelsPlainLen);
|
|
console.log(borderCh + labelsLine + " ".repeat(labelsPadding) + borderCh);
|
|
}
|
|
console.log(chalk.bold.hex("#4A90E2")("├" + "─".repeat(cols - 2) + "┤"));
|
|
|
|
// Gather and format all detail text (Body + Comments)
|
|
const contentLines: string[] = [];
|
|
|
|
// 1. Render Body
|
|
contentLines.push(chalk.bold.yellow("--- DESCRIPTION ---"));
|
|
if (issue.body.trim()) {
|
|
const wrappedBody = wordWrap(issue.body, cols - 6);
|
|
contentLines.push(...wrappedBody);
|
|
} else {
|
|
contentLines.push(chalk.italic.gray("No description provided."));
|
|
}
|
|
contentLines.push("");
|
|
|
|
// 2. Render Comments Section
|
|
contentLines.push(
|
|
chalk.bold.yellow(`--- COMMENTS (${issue.comments}) ---`),
|
|
);
|
|
|
|
if (this.state.commentsLoading) {
|
|
contentLines.push(chalk.cyan(" Loading comments..."));
|
|
} else if (this.state.selectedIssueComments.length === 0) {
|
|
contentLines.push(chalk.italic.gray(" No comments."));
|
|
} else {
|
|
for (const comment of this.state.selectedIssueComments) {
|
|
contentLines.push(
|
|
chalk.bold.cyan(`▶ ${comment.user.login}`) +
|
|
chalk.gray(` at ${formatDate(comment.created_at)}`),
|
|
);
|
|
const wrappedComment = wordWrap(comment.body, cols - 8);
|
|
for (const cLine of wrappedComment) {
|
|
contentLines.push(" " + cLine);
|
|
}
|
|
contentLines.push(""); // blank line between comments
|
|
}
|
|
}
|
|
|
|
// Paginate/Scroll calculations
|
|
const metaHeight = labels ? 3 : 2; // Meta 1, optionally Meta 2 (Labels), and Separator
|
|
const displayHeight = rows - 7 - metaHeight; // Header: 1, Borders: 3, Meta, Settings: 1, Help: 1, and -1
|
|
const maxScroll = Math.max(0, contentLines.length - displayHeight);
|
|
|
|
// Bounds check scroll offset
|
|
if (this.state.detailScrollOffset > maxScroll) {
|
|
this.state.detailScrollOffset = maxScroll;
|
|
}
|
|
|
|
// Print visible window of content
|
|
for (let r = 0; r < displayHeight; r++) {
|
|
const lineIndex = r + this.state.detailScrollOffset;
|
|
if (lineIndex < contentLines.length) {
|
|
const line = contentLines[lineIndex];
|
|
const linePlainLen = stripAnsi(line).length;
|
|
const padding = Math.max(0, cols - 4 - linePlainLen);
|
|
console.log(
|
|
borderCh + " " + line + " ".repeat(padding) + " " + borderCh,
|
|
);
|
|
} else {
|
|
console.log(borderCh + " ".repeat(cols - 2) + borderCh);
|
|
}
|
|
}
|
|
|
|
console.log(chalk.bold.hex("#4A90E2")("├" + "─".repeat(cols - 2) + "┤"));
|
|
|
|
const isSettings = this.state.focusedPane === "settings";
|
|
let refreshStr = "Off";
|
|
if (this.state.autoRefreshInterval) {
|
|
if (this.state.autoRefreshInterval >= 60) {
|
|
refreshStr = `${this.state.autoRefreshInterval / 60}m`;
|
|
} else {
|
|
refreshStr = `${this.state.autoRefreshInterval}s`;
|
|
}
|
|
}
|
|
const text = ` Auto-Refresh: ${refreshStr} `;
|
|
const coloredText = isSettings
|
|
? chalk.bgHex("#2E4E7E").white.bold(text)
|
|
: chalk.gray(` Auto-Refresh: `) + chalk.bold(refreshStr);
|
|
const paddingLength = Math.max(0, cols - 2 - stripAnsi(text).length - 1);
|
|
console.log(
|
|
borderCh + " " + coloredText + " ".repeat(paddingLength) + borderCh,
|
|
);
|
|
|
|
console.log(chalk.bold.hex("#4A90E2")("└" + "─".repeat(cols - 2) + "┘"));
|
|
|
|
// Footer Scroll percentage
|
|
let scrollHelp = " Scroll: [↑/↓] / [PgUp/PgDn]";
|
|
if (maxScroll > 0) {
|
|
const pct = Math.round((this.state.detailScrollOffset / maxScroll) * 100);
|
|
scrollHelp += chalk.yellow(` (${pct}%)`);
|
|
}
|
|
|
|
const actionKey = issue.state === "open" ? "Close" : "Reopen";
|
|
let helpLine = chalk.gray(
|
|
` [?] Help [Esc/Backspace] Back to List [C] Add Comment [A] Assign [E] Edit [T] Add Time [X] ${actionKey} [R] Reload [Tab] Menu ${scrollHelp}`,
|
|
);
|
|
if (isSettings) {
|
|
helpLine = chalk.gray(
|
|
` [?] Help [Tab] Back to Details [Enter] Toggle Auto-Refresh [Esc] Quit menu`,
|
|
);
|
|
}
|
|
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",
|
|
);
|
|
process.stdout.write(helpLine);
|
|
|
|
if (this.state.repoPickerActiveSearch) {
|
|
const searchCursorCol = 10 + this.state.repoSearchQuery.length;
|
|
process.stdout.write(`\x1B[${rows - 1};${searchCursorCol}H\x1B[?25h`); // Show cursor
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
let titleContent = "";
|
|
|
|
if (form.activeField === "title") {
|
|
titleLabel = chalk.yellow(" Title: ");
|
|
const c = Math.min(form.cursor || 0, form.title.length);
|
|
|
|
let startIdx = 0;
|
|
if (c > cols - 13) {
|
|
startIdx = c - (cols - 13) + 1;
|
|
}
|
|
|
|
const visTitle = form.title.substring(startIdx);
|
|
const visCursor = c - startIdx;
|
|
|
|
const visBefore = visTitle.substring(0, visCursor);
|
|
const visChar = visCursor < visTitle.length ? visTitle[visCursor] : " ";
|
|
const visAfter = visTitle
|
|
.substring(visCursor + 1)
|
|
.padEnd(Math.max(0, cols - 13 - visBefore.length - 1), " ")
|
|
.substring(0, Math.max(0, cols - 13 - visBefore.length - 1));
|
|
|
|
titleContent = visBefore + chalk.inverse(visChar) + visAfter;
|
|
} else {
|
|
titleVisible =
|
|
titleVisible.length > cols - 13
|
|
? titleVisible.substring(titleVisible.length - (cols - 13))
|
|
: titleVisible;
|
|
titleContent = titleVisible.padEnd(cols - 12);
|
|
}
|
|
|
|
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");
|
|
let cursorLine = 0;
|
|
let cursorCol = 0;
|
|
let remaining = Math.min(form.cursor || 0, form.body.length);
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (remaining <= lines[i].length) {
|
|
cursorLine = i;
|
|
cursorCol = remaining;
|
|
break;
|
|
}
|
|
remaining -= lines[i].length + 1;
|
|
}
|
|
|
|
let startLine = Math.max(0, lines.length - bodyRows);
|
|
if (form.activeField === "body") {
|
|
if (cursorLine < startLine) {
|
|
startLine = cursorLine;
|
|
} else if (cursorLine >= startLine + bodyRows) {
|
|
startLine = cursorLine - bodyRows + 1;
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < bodyRows; i++) {
|
|
const lineIdx = startLine + i;
|
|
let lineContent = "";
|
|
if (lineIdx < lines.length) {
|
|
lineContent = lines[lineIdx];
|
|
}
|
|
|
|
if (form.activeField === "body" && lineIdx === cursorLine) {
|
|
let startCol = 0;
|
|
if (cursorCol > cols - 6) {
|
|
startCol = cursorCol - (cols - 6) + 1;
|
|
}
|
|
|
|
const visLineStr = lineContent.substring(startCol);
|
|
const visCursorCol = cursorCol - startCol;
|
|
|
|
const before = visLineStr.substring(0, visCursorCol);
|
|
const char =
|
|
visCursorCol < visLineStr.length ? visLineStr[visCursorCol] : " ";
|
|
const after = visLineStr.substring(visCursorCol + 1);
|
|
|
|
let visLine = before + chalk.inverse(char) + after;
|
|
const plainLen = stripAnsi(visLine).length;
|
|
if (plainLen < cols - 4) {
|
|
visLine += " ".repeat(cols - 4 - plainLen);
|
|
}
|
|
|
|
console.log(borderCh + " " + visLine + " " + borderCh);
|
|
} else {
|
|
let visLineStr = lineContent;
|
|
if (visLineStr.length > cols - 5) {
|
|
visLineStr = visLineStr.substring(visLineStr.length - (cols - 5));
|
|
}
|
|
visLineStr = visLineStr.padEnd(cols - 4);
|
|
console.log(borderCh + " " + visLineStr + " " + 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 = "confirm-cancel-create";
|
|
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.state.createIssueForm.cursor =
|
|
this.state.createIssueForm[
|
|
this.state.createIssueForm.activeField
|
|
].length;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
// Text input handling
|
|
const activeField = this.state.createIssueForm.activeField;
|
|
let val = this.state.createIssueForm[activeField];
|
|
let cursor = this.state.createIssueForm.cursor;
|
|
if (cursor === undefined || cursor > val.length) cursor = val.length;
|
|
|
|
if (key && key.name === "left") {
|
|
this.state.createIssueForm.cursor = Math.max(0, cursor - 1);
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (key && key.name === "right") {
|
|
this.state.createIssueForm.cursor = Math.min(val.length, cursor + 1);
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
key &&
|
|
(key.name === "up" || key.name === "down") &&
|
|
activeField === "body"
|
|
) {
|
|
const lines = val.split("\n");
|
|
let lineIdx = 0,
|
|
col = 0,
|
|
count = 0;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (count + lines[i].length >= cursor) {
|
|
lineIdx = i;
|
|
col = cursor - count;
|
|
break;
|
|
}
|
|
count += lines[i].length + 1;
|
|
}
|
|
|
|
if (key.name === "up" && lineIdx > 0) {
|
|
const prevLine = lines[lineIdx - 1];
|
|
const newCol = Math.min(col, prevLine.length);
|
|
let newCursor = 0;
|
|
for (let i = 0; i < lineIdx - 1; i++) newCursor += lines[i].length + 1;
|
|
this.state.createIssueForm.cursor = newCursor + newCol;
|
|
this.render();
|
|
} else if (key.name === "down" && lineIdx < lines.length - 1) {
|
|
const nextLine = lines[lineIdx + 1];
|
|
const newCol = Math.min(col, nextLine.length);
|
|
let newCursor = 0;
|
|
for (let i = 0; i <= lineIdx; i++) newCursor += lines[i].length + 1;
|
|
this.state.createIssueForm.cursor = newCursor + newCol;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key && (key.name === "backspace" || key.name === "delete")) {
|
|
if (key.name === "backspace" && cursor > 0) {
|
|
this.state.createIssueForm[activeField] =
|
|
val.slice(0, cursor - 1) + val.slice(cursor);
|
|
this.state.createIssueForm.cursor = cursor - 1;
|
|
this.render();
|
|
} else if (key.name === "delete" && cursor < val.length) {
|
|
this.state.createIssueForm[activeField] =
|
|
val.slice(0, cursor) + val.slice(cursor + 1);
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key && key.name === "return") {
|
|
if (activeField === "body") {
|
|
this.state.createIssueForm.body =
|
|
val.slice(0, cursor) + "\n" + val.slice(cursor);
|
|
this.state.createIssueForm.cursor = cursor + 1;
|
|
this.render();
|
|
} else {
|
|
this.state.createIssueForm.activeField = "body";
|
|
this.state.createIssueForm.cursor =
|
|
this.state.createIssueForm.body.length;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (str && str.length === 1 && !key.ctrl && !key.meta) {
|
|
this.state.error = null;
|
|
this.state.createIssueForm[activeField] =
|
|
val.slice(0, cursor) + str + val.slice(cursor);
|
|
this.state.createIssueForm.cursor = cursor + 1;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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;
|
|
let titleContent = "";
|
|
|
|
if (form.activeField === "title") {
|
|
titleLabel = chalk.yellow(" Title: ");
|
|
const c = Math.min(form.cursor || 0, form.title.length);
|
|
|
|
let startIdx = 0;
|
|
if (c > cols - 13) {
|
|
startIdx = c - (cols - 13) + 1;
|
|
}
|
|
|
|
const visTitle = form.title.substring(startIdx);
|
|
const visCursor = c - startIdx;
|
|
|
|
const visBefore = visTitle.substring(0, visCursor);
|
|
const visChar = visCursor < visTitle.length ? visTitle[visCursor] : " ";
|
|
const visAfter = visTitle
|
|
.substring(visCursor + 1)
|
|
.padEnd(Math.max(0, cols - 13 - visBefore.length - 1), " ")
|
|
.substring(0, Math.max(0, cols - 13 - visBefore.length - 1));
|
|
|
|
titleContent = visBefore + chalk.inverse(visChar) + visAfter;
|
|
} else {
|
|
titleVisible =
|
|
titleVisible.length > cols - 13
|
|
? titleVisible.substring(titleVisible.length - (cols - 13))
|
|
: titleVisible;
|
|
titleContent = titleVisible.padEnd(cols - 12);
|
|
}
|
|
|
|
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");
|
|
let cursorLine = 0;
|
|
let cursorCol = 0;
|
|
let remaining = Math.min(form.cursor || 0, form.body.length);
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (remaining <= lines[i].length) {
|
|
cursorLine = i;
|
|
cursorCol = remaining;
|
|
break;
|
|
}
|
|
remaining -= lines[i].length + 1;
|
|
}
|
|
|
|
let startLine = Math.max(0, lines.length - bodyRows);
|
|
if (form.activeField === "body") {
|
|
if (cursorLine < startLine) {
|
|
startLine = cursorLine;
|
|
} else if (cursorLine >= startLine + bodyRows) {
|
|
startLine = cursorLine - bodyRows + 1;
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < bodyRows; i++) {
|
|
const lineIdx = startLine + i;
|
|
let lineContent = "";
|
|
if (lineIdx < lines.length) {
|
|
lineContent = lines[lineIdx];
|
|
}
|
|
|
|
if (form.activeField === "body" && lineIdx === cursorLine) {
|
|
let startCol = 0;
|
|
if (cursorCol > cols - 6) {
|
|
startCol = cursorCol - (cols - 6) + 1;
|
|
}
|
|
|
|
const visLineStr = lineContent.substring(startCol);
|
|
const visCursorCol = cursorCol - startCol;
|
|
|
|
const before = visLineStr.substring(0, visCursorCol);
|
|
const char =
|
|
visCursorCol < visLineStr.length ? visLineStr[visCursorCol] : " ";
|
|
const after = visLineStr.substring(visCursorCol + 1);
|
|
|
|
let visLine = before + chalk.inverse(char) + after;
|
|
const plainLen = stripAnsi(visLine).length;
|
|
if (plainLen < cols - 4) {
|
|
visLine += " ".repeat(cols - 4 - plainLen);
|
|
}
|
|
|
|
console.log(borderCh + " " + visLine + " " + borderCh);
|
|
} else {
|
|
let visLineStr = lineContent;
|
|
if (visLineStr.length > cols - 5) {
|
|
visLineStr = visLineStr.substring(visLineStr.length - (cols - 5));
|
|
}
|
|
visLineStr = visLineStr.padEnd(cols - 4);
|
|
console.log(borderCh + " " + visLineStr + " " + 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 = "confirm-cancel-edit";
|
|
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.state.editIssueForm.cursor =
|
|
this.state.editIssueForm[this.state.editIssueForm.activeField].length;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
// Text input handling
|
|
const activeField = this.state.editIssueForm.activeField;
|
|
let val = this.state.editIssueForm[activeField];
|
|
let cursor = this.state.editIssueForm.cursor;
|
|
if (cursor === undefined || cursor > val.length) cursor = val.length;
|
|
|
|
if (key && key.name === "left") {
|
|
this.state.editIssueForm.cursor = Math.max(0, cursor - 1);
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (key && key.name === "right") {
|
|
this.state.editIssueForm.cursor = Math.min(val.length, cursor + 1);
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
key &&
|
|
(key.name === "up" || key.name === "down") &&
|
|
activeField === "body"
|
|
) {
|
|
const lines = val.split("\n");
|
|
let lineIdx = 0,
|
|
col = 0,
|
|
count = 0;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (count + lines[i].length >= cursor) {
|
|
lineIdx = i;
|
|
col = cursor - count;
|
|
break;
|
|
}
|
|
count += lines[i].length + 1;
|
|
}
|
|
|
|
if (key.name === "up" && lineIdx > 0) {
|
|
const prevLine = lines[lineIdx - 1];
|
|
const newCol = Math.min(col, prevLine.length);
|
|
let newCursor = 0;
|
|
for (let i = 0; i < lineIdx - 1; i++) newCursor += lines[i].length + 1;
|
|
this.state.editIssueForm.cursor = newCursor + newCol;
|
|
this.render();
|
|
} else if (key.name === "down" && lineIdx < lines.length - 1) {
|
|
const nextLine = lines[lineIdx + 1];
|
|
const newCol = Math.min(col, nextLine.length);
|
|
let newCursor = 0;
|
|
for (let i = 0; i <= lineIdx; i++) newCursor += lines[i].length + 1;
|
|
this.state.editIssueForm.cursor = newCursor + newCol;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key && (key.name === "backspace" || key.name === "delete")) {
|
|
if (key.name === "backspace" && cursor > 0) {
|
|
this.state.editIssueForm[activeField] =
|
|
val.slice(0, cursor - 1) + val.slice(cursor);
|
|
this.state.editIssueForm.cursor = cursor - 1;
|
|
this.render();
|
|
} else if (key.name === "delete" && cursor < val.length) {
|
|
this.state.editIssueForm[activeField] =
|
|
val.slice(0, cursor) + val.slice(cursor + 1);
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key && key.name === "return") {
|
|
if (activeField === "body") {
|
|
this.state.editIssueForm.body =
|
|
val.slice(0, cursor) + "\n" + val.slice(cursor);
|
|
this.state.editIssueForm.cursor = cursor + 1;
|
|
this.render();
|
|
} else {
|
|
this.state.editIssueForm.activeField = "body";
|
|
this.state.editIssueForm.cursor = this.state.editIssueForm.body.length;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (str && str.length === 1 && !key.ctrl && !key.meta) {
|
|
this.state.error = null;
|
|
this.state.editIssueForm[activeField] =
|
|
val.slice(0, cursor) + str + val.slice(cursor);
|
|
this.state.editIssueForm.cursor = cursor + 1;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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;
|
|
const newState = issue.state === "open" ? "closed" : "open";
|
|
this.state.screen =
|
|
newState === "closed" ? "animating-close" : "animating-reopen";
|
|
this.animationFrame = 0;
|
|
this.render();
|
|
|
|
if (this.animationInterval) {
|
|
clearInterval(this.animationInterval);
|
|
}
|
|
|
|
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);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
private renderConfirmCancelCreateScreen(cols: number, rows: number) {
|
|
const borderCh = chalk.bold.hex("#4A90E2")("│");
|
|
|
|
const title = " Confirm Cancel ";
|
|
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) + "┐"));
|
|
|
|
const question = "Are you sure you want to cancel creating this issue?";
|
|
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 padContentLeft = Math.max(
|
|
0,
|
|
Math.floor((cols - 2 - promptPlain.length) / 2),
|
|
);
|
|
const contentLine = (
|
|
" ".repeat(padContentLeft) +
|
|
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 handleConfirmCancelCreateKeypress(str: string, key: any) {
|
|
if (
|
|
(key && (key.name === "escape" || key.name === "n")) ||
|
|
str === "n" ||
|
|
str === "N"
|
|
) {
|
|
this.state.screen = "create-issue";
|
|
this.state.error = null;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (str === "y" || str === "Y") {
|
|
this.state.screen = "list";
|
|
this.state.error = null;
|
|
this.render();
|
|
return;
|
|
}
|
|
}
|
|
|
|
private renderConfirmCancelEditScreen(cols: number, rows: number) {
|
|
const borderCh = chalk.bold.hex("#4A90E2")("│");
|
|
|
|
const title = " Confirm Cancel ";
|
|
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) + "┐"));
|
|
|
|
const question = "Are you sure you want to cancel editing this issue?";
|
|
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 padContentLeft = Math.max(
|
|
0,
|
|
Math.floor((cols - 2 - promptPlain.length) / 2),
|
|
);
|
|
const contentLine = (
|
|
" ".repeat(padContentLeft) +
|
|
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 handleConfirmCancelEditKeypress(str: string, key: any) {
|
|
if (
|
|
(key && (key.name === "escape" || key.name === "n")) ||
|
|
str === "n" ||
|
|
str === "N"
|
|
) {
|
|
this.state.screen = "edit-issue";
|
|
this.state.error = null;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (str === "y" || str === "Y") {
|
|
this.state.screen = "details";
|
|
this.state.error = null;
|
|
this.render();
|
|
return;
|
|
}
|
|
}
|
|
|
|
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) + "┘"));
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
// LAUNCH ANIMATION IMPLEMENTATION
|
|
private startLaunchAnimation() {
|
|
this.launchFrameIndex = 0;
|
|
this.render();
|
|
|
|
this.launchInterval = setInterval(() => {
|
|
this.launchFrameIndex++;
|
|
this.render();
|
|
|
|
if (this.launchFrameIndex >= this.maxLaunchFrames - 1) {
|
|
this.skipLaunchAnimation();
|
|
}
|
|
}, 60); // 60ms interval for fast, smooth fluid color shifting
|
|
}
|
|
|
|
private skipLaunchAnimation() {
|
|
if (this.launchInterval) {
|
|
clearInterval(this.launchInterval);
|
|
this.launchInterval = null;
|
|
}
|
|
|
|
// Transition to target destination screen
|
|
if (this.launchDestScreen === "list") {
|
|
this.state.screen = "list";
|
|
this.loadIssues();
|
|
} else {
|
|
this.state.screen = "setup";
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
private loadLaunchFrames() {
|
|
try {
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
// Resolve path to the root folder where ascii-art.txt is located
|
|
const artPath = path.resolve(__dirname, "..", "ascii-art.txt");
|
|
|
|
if (fs.existsSync(artPath)) {
|
|
const rawArt = fs.readFileSync(artPath, "utf8").split(/\r?\n/);
|
|
this.launchFrame = this.preprocessArt(rawArt);
|
|
} else {
|
|
// Fallback frames if file not found
|
|
this.launchFrame = [" ( ( ", " ) ) ", " [===] ", " \\___/ "];
|
|
}
|
|
} catch (e) {
|
|
this.launchFrame = [" ( ( ", " ) ) ", " [===] ", " \\___/ "];
|
|
}
|
|
}
|
|
|
|
private preprocessArt(lines: string[]): string[] {
|
|
let startIdx = 0;
|
|
while (startIdx < lines.length && lines[startIdx].trim() === "") {
|
|
startIdx++;
|
|
}
|
|
let endIdx = lines.length - 1;
|
|
while (endIdx >= 0 && lines[endIdx].trim() === "") {
|
|
endIdx--;
|
|
}
|
|
|
|
if (startIdx > endIdx) return [];
|
|
const activeLines = lines.slice(startIdx, endIdx + 1);
|
|
|
|
let minPadding = Infinity;
|
|
for (const line of activeLines) {
|
|
if (line.trim() === "") continue;
|
|
const match = line.match(/^[ \t]*/);
|
|
const padding = match ? match[0].length : 0;
|
|
if (padding < minPadding) {
|
|
minPadding = padding;
|
|
}
|
|
}
|
|
|
|
if (minPadding === Infinity || minPadding === 0) return activeLines;
|
|
return activeLines.map((line) =>
|
|
line.length >= minPadding ? line.substring(minPadding) : "",
|
|
);
|
|
}
|
|
|
|
private renderLaunchScreen(cols: number, rows: number) {
|
|
if (this.launchFrame.length === 0) {
|
|
this.loadLaunchFrames();
|
|
}
|
|
|
|
const frame = this.launchFrame;
|
|
if (frame.length === 0) return;
|
|
|
|
const W = Math.max(...frame.map((line) => line.length));
|
|
const H = frame.length;
|
|
|
|
const padLeftCount = Math.max(0, Math.floor((cols - W) / 2));
|
|
const padTopCount = Math.max(0, Math.floor((rows - H - 5) / 2));
|
|
|
|
for (let i = 0; i < padTopCount; i++) {
|
|
console.log("");
|
|
}
|
|
|
|
// HSL helper function to generate HSL-based hex colors
|
|
const hslToHex = (h: number, s: number, l: number): string => {
|
|
l /= 100;
|
|
const a = (s * Math.min(l, 1 - l)) / 100;
|
|
const f = (n: number) => {
|
|
const k = (n + h / 30) % 12;
|
|
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
return Math.round(255 * color)
|
|
.toString(16)
|
|
.padStart(2, "0");
|
|
};
|
|
return `#${f(0)}${f(8)}${f(4)}`;
|
|
};
|
|
|
|
const step = this.launchFrameIndex;
|
|
|
|
for (let i = 0; i < frame.length; i++) {
|
|
const line = frame[i];
|
|
const leftPad = " ".repeat(padLeftCount);
|
|
|
|
let coloredLine = "";
|
|
if (i < 20) {
|
|
// Steam gradient: waving light intensity using sine wave based on time and row index
|
|
const factor = i / 20; // 0 to 1
|
|
|
|
// Wave lightness of the steam
|
|
const wave = Math.sin(step * 0.4 - i * 0.3);
|
|
const lightness = Math.max(
|
|
10,
|
|
Math.min(80, Math.round(25 + wave * 15 + factor * 25)),
|
|
);
|
|
|
|
// Soft cyan/blue steam hue = 195
|
|
const hex = hslToHex(195, 30, lightness);
|
|
coloredLine = chalk.hex(hex)(line);
|
|
} else {
|
|
// Cup body gradient: shifting rainbow wave
|
|
const rowOffset = i - 20;
|
|
|
|
// Cycle the hue over time and rows
|
|
const hue = (step * 8 + rowOffset * 12) % 360;
|
|
const hex = hslToHex(hue, 95, 55);
|
|
coloredLine = chalk.hex(hex).bold(line);
|
|
}
|
|
|
|
console.log(leftPad + coloredLine);
|
|
}
|
|
|
|
console.log("");
|
|
const titlePlain = " G I T E A & F O R G E J O T U I ";
|
|
const subtitlePlain = "Interactive Issue & Pull Request Explorer";
|
|
|
|
// Cycle the title box background color too!
|
|
const titleHue = (step * 6) % 360;
|
|
const titleColor = hslToHex(titleHue, 95, 50);
|
|
|
|
const titleLeftPad = Math.max(
|
|
0,
|
|
Math.floor((cols - titlePlain.length) / 2),
|
|
);
|
|
const subtitleLeftPad = Math.max(
|
|
0,
|
|
Math.floor((cols - subtitlePlain.length) / 2),
|
|
);
|
|
|
|
console.log(
|
|
" ".repeat(titleLeftPad) + chalk.bold.bgHex(titleColor).black(titlePlain),
|
|
);
|
|
console.log(" ".repeat(subtitleLeftPad) + chalk.gray(subtitlePlain));
|
|
console.log("");
|
|
|
|
const skipPlain = "Press any key to skip animation";
|
|
const skipLeftPad = Math.max(0, Math.floor((cols - skipPlain.length) / 2));
|
|
console.log(" ".repeat(skipLeftPad) + chalk.gray.italic(skipPlain));
|
|
|
|
const printedRows = H + padTopCount + 5;
|
|
const remainingRows = Math.max(0, rows - printedRows - 1);
|
|
}
|
|
|
|
// --- Labels Logic ---
|
|
private async loadLabels() {
|
|
this.state.labelsLoading = true;
|
|
this.state.error = null;
|
|
this.render();
|
|
try {
|
|
this.state.labels = await fetchLabels(this.state.config);
|
|
} catch (err: any) {
|
|
this.state.error = `Failed to load labels: ${err.message}`;
|
|
} finally {
|
|
this.state.labelsLoading = false;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
private async handleLabelsListKeypress(str: string, key: any) {
|
|
if (this.state.labelsLoading) return;
|
|
|
|
if (
|
|
(key && key.name === "escape") ||
|
|
str === "\u001b" ||
|
|
str === "q" ||
|
|
str === "Q"
|
|
) {
|
|
this.state.screen = "list";
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if ((key && key.name === "up") || str === "k") {
|
|
if (this.state.selectedLabelIndex > 0) {
|
|
this.state.selectedLabelIndex--;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ((key && key.name === "down") || str === "j") {
|
|
if (this.state.selectedLabelIndex < this.state.labels.length - 1) {
|
|
this.state.selectedLabelIndex++;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (str === "c" || str === "C") {
|
|
this.state.screen = "create-label";
|
|
this.state.labelForm = {
|
|
name: "",
|
|
color: "000000",
|
|
description: "",
|
|
exclusive: false,
|
|
activeField: "name",
|
|
};
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (str === "e" || str === "E") {
|
|
if (this.state.labels.length > 0) {
|
|
const lbl = this.state.labels[this.state.selectedLabelIndex];
|
|
this.state.screen = "edit-label";
|
|
this.state.labelForm = {
|
|
name: lbl.name,
|
|
color: lbl.color,
|
|
description: lbl.description || "",
|
|
exclusive: lbl.exclusive || false,
|
|
activeField: "name",
|
|
};
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ((key && key.name === "delete") || str === "d" || str === "D") {
|
|
if (this.state.labels.length > 0) {
|
|
const lbl = this.state.labels[this.state.selectedLabelIndex];
|
|
this.state.labelsLoading = true;
|
|
this.render();
|
|
try {
|
|
await deleteLabel(this.state.config, lbl.id);
|
|
await this.loadLabels();
|
|
} catch (err: any) {
|
|
this.state.error = `Failed to delete label: ${err.message}`;
|
|
this.state.labelsLoading = false;
|
|
this.render();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
private async handleLabelFormKeypress(str: string, key: any) {
|
|
if ((key && key.name === "escape") || str === "\u001b") {
|
|
this.state.screen = "labels-list";
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
const form = this.state.labelForm;
|
|
const fields: Array<"name" | "color" | "description" | "exclusive"> = [
|
|
"name",
|
|
"color",
|
|
"description",
|
|
"exclusive",
|
|
];
|
|
const currentIdx = fields.indexOf(form.activeField);
|
|
|
|
if (key && key.name === "up") {
|
|
form.activeField =
|
|
fields[(currentIdx - 1 + fields.length) % fields.length];
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (key && key.name === "down") {
|
|
form.activeField = fields[(currentIdx + 1) % fields.length];
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (key && key.name === "tab") {
|
|
form.activeField = fields[(currentIdx + 1) % fields.length];
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (str === " " && form.activeField === "exclusive") {
|
|
form.exclusive = !form.exclusive;
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if ((key && key.name === "return") || str === "\r" || str === "\n") {
|
|
if (form.activeField === "exclusive") {
|
|
form.exclusive = !form.exclusive;
|
|
this.render();
|
|
return;
|
|
}
|
|
if (!form.name.trim() || !form.color.trim()) {
|
|
this.state.error = "Name and Color are required.";
|
|
this.render();
|
|
return;
|
|
}
|
|
this.state.labelsLoading = true;
|
|
this.state.error = null;
|
|
this.render();
|
|
|
|
try {
|
|
if (this.state.screen === "create-label") {
|
|
await createLabel(this.state.config, {
|
|
name: form.name.trim(),
|
|
color: form.color.trim(),
|
|
description: form.description.trim(),
|
|
exclusive: form.exclusive,
|
|
});
|
|
} else {
|
|
const lbl = this.state.labels[this.state.selectedLabelIndex];
|
|
await updateLabel(this.state.config, lbl.id, {
|
|
name: form.name.trim(),
|
|
color: form.color.trim(),
|
|
description: form.description.trim(),
|
|
exclusive: form.exclusive,
|
|
});
|
|
}
|
|
this.state.screen = "labels-list";
|
|
await this.loadLabels();
|
|
} catch (err: any) {
|
|
this.state.error = err.message;
|
|
this.state.labelsLoading = false;
|
|
this.render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key && key.name === "backspace") {
|
|
if (form.activeField === "name") form.name = form.name.slice(0, -1);
|
|
if (form.activeField === "color") form.color = form.color.slice(0, -1);
|
|
if (form.activeField === "description")
|
|
form.description = form.description.slice(0, -1);
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
str &&
|
|
!key.ctrl &&
|
|
!key.meta &&
|
|
str.length === 1 &&
|
|
str.charCodeAt(0) >= 32
|
|
) {
|
|
if (form.activeField === "name") form.name += str;
|
|
if (form.activeField === "color") form.color += str;
|
|
if (form.activeField === "description") form.description += str;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
private renderLabelsList(cols: number, rows: number) {
|
|
const header = chalk.bgBlue.white.bold(
|
|
` Repository Labels - ${this.state.config.owner}/${this.state.config.repo} `,
|
|
);
|
|
const headerPad = Math.max(
|
|
0,
|
|
Math.floor((cols - stripAnsi(header).length) / 2),
|
|
);
|
|
console.log(" ".repeat(headerPad) + header);
|
|
console.log(chalk.gray("─".repeat(cols)));
|
|
|
|
if (this.state.labelsLoading) {
|
|
console.log(
|
|
chalk.yellow(` Loading labels... ${SPINNER_FRAMES[spinnerIndex]}`),
|
|
);
|
|
} else if (this.state.labels.length === 0) {
|
|
console.log(chalk.gray(" No labels found."));
|
|
} else {
|
|
const listHeight = rows - 6; // header(2), footer(2), errors etc.
|
|
let startIdx = 0;
|
|
if (this.state.selectedLabelIndex >= listHeight) {
|
|
startIdx = this.state.selectedLabelIndex - listHeight + 1;
|
|
}
|
|
|
|
for (let i = 0; i < listHeight; i++) {
|
|
const itemIdx = startIdx + i;
|
|
if (itemIdx >= this.state.labels.length) break;
|
|
|
|
const lbl = this.state.labels[itemIdx];
|
|
const isSelected = itemIdx === this.state.selectedLabelIndex;
|
|
|
|
let prefix = isSelected ? chalk.green(" > ") : " ";
|
|
let lblDisplay = chalk.bgHex("#" + lbl.color).black(` ${lbl.name} `);
|
|
if (lbl.exclusive) lblDisplay += " (exclusive)";
|
|
|
|
let line = prefix + lblDisplay;
|
|
if (lbl.description) {
|
|
line += chalk.gray(` - ${truncate(lbl.description, cols - 30)}`);
|
|
}
|
|
|
|
if (isSelected) {
|
|
console.log(
|
|
chalk.bgGray(
|
|
line + " ".repeat(Math.max(0, cols - stripAnsi(line).length)),
|
|
),
|
|
);
|
|
} else {
|
|
console.log(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fill remaining space
|
|
const currentLine = this.state.labelsLoading
|
|
? 3
|
|
: Math.min(this.state.labels.length, rows - 6) + 2;
|
|
for (let i = currentLine; i < rows - 3; i++) console.log("");
|
|
|
|
if (this.state.error) {
|
|
console.log(chalk.red(` Error: ${this.state.error}`));
|
|
} else {
|
|
console.log("");
|
|
}
|
|
|
|
console.log(chalk.gray("─".repeat(cols)));
|
|
console.log(
|
|
chalk.gray(" [c] Create [e] Edit [d] Delete [esc] Back to Issues"),
|
|
);
|
|
}
|
|
|
|
private renderLabelForm(cols: number, rows: number) {
|
|
const isEdit = this.state.screen === "edit-label";
|
|
const title = isEdit ? "Edit Label" : "Create Label";
|
|
const header = chalk.bgBlue.white.bold(` ${title} `);
|
|
const headerPad = Math.max(
|
|
0,
|
|
Math.floor((cols - stripAnsi(header).length) / 2),
|
|
);
|
|
console.log(" ".repeat(headerPad) + header);
|
|
console.log(chalk.gray("─".repeat(cols)));
|
|
|
|
const form = this.state.labelForm;
|
|
const width = Math.min(60, cols - 4);
|
|
const leftPadStr = " ".repeat(Math.max(0, Math.floor((cols - width) / 2)));
|
|
|
|
console.log("");
|
|
|
|
// Name
|
|
const nameLabel =
|
|
form.activeField === "name" ? chalk.cyan.bold("> Name: ") : " Name: ";
|
|
console.log(leftPadStr + nameLabel + (form.name || chalk.gray("(empty)")));
|
|
console.log("");
|
|
|
|
// Color
|
|
const colorLabel =
|
|
form.activeField === "color"
|
|
? chalk.cyan.bold("> Color (hex without #): ")
|
|
: " Color: ";
|
|
console.log(
|
|
leftPadStr + colorLabel + (form.color || chalk.gray("e.g. ff0000")),
|
|
);
|
|
if (form.color && form.color.length >= 3) {
|
|
try {
|
|
console.log(
|
|
leftPadStr +
|
|
" Preview: " +
|
|
chalk.bgHex("#" + form.color).black(` ${form.name || "Label"} `),
|
|
);
|
|
} catch (e) {
|
|
console.log(leftPadStr + " Preview: " + chalk.red("Invalid color"));
|
|
}
|
|
}
|
|
console.log("");
|
|
|
|
// Description
|
|
const descLabel =
|
|
form.activeField === "description"
|
|
? chalk.cyan.bold("> Description: ")
|
|
: " Description: ";
|
|
console.log(
|
|
leftPadStr + descLabel + (form.description || chalk.gray("(empty)")),
|
|
);
|
|
console.log("");
|
|
|
|
// Exclusive
|
|
const exclLabel =
|
|
form.activeField === "exclusive"
|
|
? chalk.cyan.bold("> Exclusive: ")
|
|
: " Exclusive: ";
|
|
console.log(
|
|
leftPadStr + exclLabel + (form.exclusive ? chalk.green("[x]") : "[ ]"),
|
|
);
|
|
console.log("");
|
|
|
|
for (let i = 13; i < rows - 3; i++) console.log("");
|
|
|
|
if (this.state.error) {
|
|
console.log(leftPadStr + chalk.red(` Error: ${this.state.error}`));
|
|
} else if (this.state.labelsLoading) {
|
|
console.log(
|
|
leftPadStr + chalk.yellow(` Saving... ${SPINNER_FRAMES[spinnerIndex]}`),
|
|
);
|
|
} else {
|
|
console.log("");
|
|
}
|
|
|
|
console.log(chalk.gray("─".repeat(cols)));
|
|
console.log(chalk.gray(" [↑/↓/Tab] Navigate [Enter] Save [Esc] Cancel"));
|
|
}
|
|
|
|
private renderHelpScreen(cols: number, rows: number) {
|
|
const isDetails = this.state.previousScreen === "details";
|
|
|
|
// Command lists:
|
|
const commands: Array<{ key: string; desc: string }> = isDetails
|
|
? [
|
|
{ key: "Esc / Backspace", desc: "Back to Issues List" },
|
|
{ key: "C", desc: "Add Comment to Issue" },
|
|
{ key: "A", desc: "Assign User(s)" },
|
|
{ key: "E", desc: "Edit Title & Description" },
|
|
{ key: "T", desc: "Track Time (add time)" },
|
|
{ key: "X", desc: "Close / Reopen Issue" },
|
|
{ key: "R", desc: "Reload Single Issue" },
|
|
{ key: "O", desc: "Global Setup / Connection Settings" },
|
|
{ key: "↑ / ↓", desc: "Scroll description & comments" },
|
|
{ key: "PgUp / PgDn", desc: "Scroll faster (10 lines)" },
|
|
{ key: "?", desc: "Show / Close Help Menu" },
|
|
]
|
|
: [
|
|
{ key: "↑ / ↓ / j/k", desc: "Navigate issues list" },
|
|
{ key: "Enter", desc: "View selected issue details" },
|
|
{ key: "C", desc: "Create new issue" },
|
|
{ key: "/", desc: "Focus Search input field" },
|
|
{ key: "S", desc: "Cycle Sort fields and order" },
|
|
{ key: "F", desc: "Cycle State filter (open/closed/all)" },
|
|
{ key: "T", desc: "Cycle Type filter (issues/pulls/all)" },
|
|
{ key: "L", desc: "View and manage Labels list" },
|
|
{ key: "N / PageDown", desc: "Next page of issues" },
|
|
{ key: "P / PageUp", desc: "Previous page of issues" },
|
|
{ key: "R", desc: "Reload issues list" },
|
|
{ key: "Tab", desc: "Toggle focus (Issues List <=> Settings)" },
|
|
{ key: "O", desc: "Global Setup / Connection Settings" },
|
|
{ key: "Q / Esc", desc: "Quit application" },
|
|
{ key: "?", desc: "Show / Close Help Menu" },
|
|
];
|
|
|
|
const title = ` KEYBOARD COMMANDS HELP (${isDetails ? "DETAILS VIEW" : "LIST VIEW"}) `;
|
|
const width = Math.min(74, cols - 4);
|
|
const height = commands.length + 6; // list + borders + title + instructions
|
|
|
|
const leftPadStr = " ".repeat(Math.max(0, Math.floor((cols - width) / 2)));
|
|
|
|
// Top border
|
|
console.log("\n"); // blank line
|
|
const borderCh = chalk.bold.hex("#4A90E2");
|
|
|
|
const titlePadding = "─".repeat(
|
|
Math.max(0, Math.floor((width - 2 - title.length) / 2)),
|
|
);
|
|
const titleLine =
|
|
borderCh("┌" + titlePadding + title + titlePadding) +
|
|
borderCh(
|
|
"─".repeat(
|
|
Math.max(0, width - 2 - title.length - 2 * titlePadding.length),
|
|
) + "┐",
|
|
);
|
|
console.log(leftPadStr + titleLine);
|
|
|
|
// Render lines
|
|
for (const cmd of commands) {
|
|
const keyStr = chalk.bold.cyan(cmd.key.padStart(22));
|
|
const descStr = chalk.white(cmd.desc);
|
|
const row = ` ${keyStr} ─ ${descStr}`;
|
|
const rowPlainLen = stripAnsi(row).length;
|
|
const rowPadding = " ".repeat(Math.max(0, width - 2 - rowPlainLen));
|
|
console.log(
|
|
leftPadStr + borderCh("│") + row + rowPadding + borderCh("│"),
|
|
);
|
|
}
|
|
|
|
// Separator line
|
|
console.log(leftPadStr + borderCh("├" + "─".repeat(width - 2) + "┤"));
|
|
|
|
// Instructions line
|
|
const instructions = "Press [Esc], [q], or [?] to close";
|
|
const instrPadding = " ".repeat(
|
|
Math.max(0, Math.floor((width - 2 - instructions.length) / 2)),
|
|
);
|
|
const instrLine =
|
|
borderCh("│") +
|
|
instrPadding +
|
|
chalk.yellow.bold(instructions) +
|
|
" ".repeat(
|
|
Math.max(0, width - 2 - instructions.length - instrPadding.length),
|
|
) +
|
|
borderCh("│");
|
|
console.log(leftPadStr + instrLine);
|
|
|
|
// Bottom border
|
|
console.log(leftPadStr + borderCh("└" + "─".repeat(width - 2) + "┘"));
|
|
|
|
// Fill remaining rows
|
|
const filledRows = 1 + 1 + height + 1; // offset + top border + height + newlines
|
|
for (let i = filledRows; i < rows; i++) {
|
|
console.log("");
|
|
}
|
|
}
|
|
}
|