diff --git a/src/api.ts b/src/api.ts index 3b767c8..116164e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -248,3 +248,50 @@ export async function authenticateAndFetchRepos( } } +/** + * Creates a new issue in the specified repository. + */ +export async function createIssue( + config: Config, + title: string, + body: string +): Promise { + const client = createAxiosInstance(config); + try { + const response = await client.post(`/repos/${config.owner}/${config.repo}/issues`, { + title, + body, + }); + + const item = response.data; + return { + id: item.id, + number: item.number, + title: item.title, + state: item.state, + body: item.body || '', + user: { + id: item.user?.id || 0, + login: item.user?.login || 'unknown', + full_name: item.user?.full_name || '', + }, + created_at: item.created_at, + updated_at: item.updated_at, + comments: item.comments_count || item.comments || 0, + labels: (item.labels || []).map((l: any) => ({ + id: l.id, + name: l.name, + color: l.color, + })), + pull_request: item.pull_request, + }; + } catch (error: any) { + if (error.response) { + if (error.response.status === 401) { + throw new Error('Unauthorized: You must be logged in with a token to create an issue.'); + } + throw new Error(`Failed to create issue: ${error.response.data?.message || error.message}`); + } + throw new Error(`Network Error: ${error.message}`); + } +} diff --git a/src/index.ts b/src/index.ts index 0371a83..1e72a37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,11 @@ async function bootstrap() { saveConfig: true, activeField: url ? (userid ? 'token' : 'userid') : 'url', }, + createIssueForm: { + title: '', + body: '', + activeField: 'title', + }, }; // If parameters are provided, try direct connection first diff --git a/src/tui.ts b/src/tui.ts index 0e271d1..a736a13 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -1,7 +1,7 @@ import readline from 'readline'; import chalk from 'chalk'; import { AppState, Issue, Comment } from './types.js'; -import { fetchIssues, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos } from './api.js'; +import { fetchIssues, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue } from './api.js'; import { saveGlobalConfig } from './config.js'; // Setup readline for stdin keypress events @@ -246,6 +246,8 @@ export class TuiEngine { } } else if (this.state.screen === 'details') { this.handleDetailsKeypress(str, key); + } else if (this.state.screen === 'create-issue') { + this.handleCreateIssueKeypress(str, key); } } @@ -581,6 +583,16 @@ export class TuiEngine { 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.render(); + return; + } if (str === 'f' || str === 'F') { // Cycle states: open -> closed -> all @@ -681,6 +693,8 @@ export class TuiEngine { 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); } } @@ -955,7 +969,7 @@ export class TuiEngine { console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘')); // Keyboard controls help line - const fullHelpLine = ' [↑/↓] Navigate [Enter] View [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [O] Settings [Esc] Quit'; + const fullHelpLine = ' [↑/↓] Navigate [Enter] View [C] Create [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [O] Settings [Esc] Quit'; const repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `); const repoLen = stripAnsi(repoStr).length; @@ -1166,5 +1180,169 @@ export class TuiEngine { 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; + if (titleVisible.length > cols - 13) { + titleVisible = titleVisible.substring(titleVisible.length - (cols - 13)); + } + let titleContent = titleVisible.padEnd(cols - 12); + if (form.activeField === 'title') { + titleLabel = chalk.yellow(' Title: '); + titleContent = chalk.inverse(titleVisible + ' ') + ' '.repeat(Math.max(0, cols - 13 - titleVisible.length)); + } + console.log(borderCh + titleLabel + titleContent + borderCh); + console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); + + // Body Field + let bodyLabel = chalk.bold(' Body (markdown supported): '); + if (form.activeField === 'body') { + bodyLabel = chalk.yellow.bold(' Body (markdown supported): '); + } + console.log(borderCh + bodyLabel + ' '.repeat(cols - stripAnsi(bodyLabel).length - 2) + borderCh); + console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); + + // Body Content (multiline) + const errorOffset = this.state.error ? 2 : 0; + const bodyRows = rows - 10 - errorOffset; // available space for body + const lines = form.body.split('\n'); + + // Pagination for body if it gets too long + const startLine = Math.max(0, lines.length - bodyRows); + + for (let i = 0; i < bodyRows; i++) { + const lineIdx = startLine + i; + let lineContent = ''; + if (lineIdx < lines.length) { + lineContent = lines[lineIdx]; + } + + let visLine = lineContent; + if (visLine.length > cols - 5) { + visLine = visLine.substring(visLine.length - (cols - 5)); + } + + if (form.activeField === 'body' && lineIdx === lines.length - 1) { + visLine = visLine + chalk.inverse(' ') + ' '.repeat(Math.max(0, cols - 5 - stripAnsi(visLine).length)); + } else { + visLine = visLine.padEnd(cols - 4); + } + + console.log(borderCh + ' ' + visLine + ' ' + borderCh); + } + + console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘')); + + // Help line + const helpLine = chalk.gray(' [Tab] Switch Fields [Ctrl+S] Submit [Esc] Cancel'); + const repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `); + const repoLen = stripAnsi(repoStr).length; + + const maxHelpLen = cols - repoLen - 1; + let helpLinePlain = helpLine; + if (stripAnsi(helpLinePlain).length > maxHelpLen) { + helpLinePlain = chalk.gray(stripAnsi(helpLinePlain).substring(0, maxHelpLen - 3) + '...'); + } + + const spaces = cols - stripAnsi(helpLinePlain).length - repoLen - 1; + process.stdout.write(helpLinePlain + ' '.repeat(Math.max(0, spaces)) + repoStr); + } + + /** + * Handle Keypress for Create Issue Screen + */ + private async handleCreateIssueKeypress(str: string, key: any) { + if (key && key.name === 'escape') { + this.state.screen = 'list'; + this.state.error = null; + this.render(); + return; + } + + if (key && key.ctrl && key.name === 's') { + // Submit issue + if (!this.state.createIssueForm.title.trim()) { + this.state.error = 'Title is required to create an issue.'; + this.render(); + return; + } + + this.state.error = null; + this.state.loading = true; + this.render(); + try { + await createIssue(this.state.config, this.state.createIssueForm.title, this.state.createIssueForm.body); + this.state.screen = 'list'; + this.state.loading = false; + // Refresh issues + this.state.currentPage = 1; + this.state.selectedIssueIndex = 0; + this.loadIssues(); + } catch (err: any) { + this.state.error = err.message; + this.state.loading = false; + this.render(); + } + return; + } + + if (key && key.name === 'tab') { + this.state.createIssueForm.activeField = this.state.createIssueForm.activeField === 'title' ? 'body' : 'title'; + this.render(); + return; + } + + // Text input handling + const activeField = this.state.createIssueForm.activeField; + let val = this.state.createIssueForm[activeField]; + + if (key && key.name === 'backspace') { + if (val.length > 0) { + this.state.createIssueForm[activeField] = val.slice(0, -1); + this.render(); + } + return; + } + + if (key && key.name === 'return') { + if (activeField === 'body') { + this.state.createIssueForm.body += '\n'; + this.render(); + } else { + // In title, return switches to body + this.state.createIssueForm.activeField = 'body'; + this.render(); + } + return; + } + + if (str && str.length === 1 && !key.ctrl && !key.meta) { + this.state.error = null; + this.state.createIssueForm[activeField] += str; + this.render(); + } + } } diff --git a/src/types.ts b/src/types.ts index e087dd4..5f6886d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,7 +48,13 @@ export interface SetupForm { activeField: 'url' | 'userid' | 'token' | 'saveConfig'; } -export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details'; +export interface CreateIssueForm { + title: string; + body: string; + activeField: 'title' | 'body'; +} + +export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue'; export interface RepoItem { id: number; @@ -90,5 +96,8 @@ export interface AppState { // Setup Form State setupForm: SetupForm; + + // Create Issue Form State + createIssueForm: CreateIssueForm; }