From 506700caf91b1194163a4faf17ddbac27d8ed0e0 Mon Sep 17 00:00:00 2001 From: Isaac Johnson Date: Sun, 31 May 2026 21:02:23 -0500 Subject: [PATCH] login and settings --- src/api.ts | 80 ++++++++++++++- src/index.ts | 13 ++- src/tui.ts | 275 +++++++++++++++++++++++++++++++++++++++++++-------- src/types.ts | 22 ++++- 4 files changed, 341 insertions(+), 49 deletions(-) diff --git a/src/api.ts b/src/api.ts index 9112342..3b767c8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { Config, Issue, Comment } from './types.js'; +import { Config, Issue, Comment, RepoItem } from './types.js'; /** * Normalizes the Gitea/Forgejo URL by stripping trailing slashes @@ -170,3 +170,81 @@ export async function fetchIssueComments( throw new Error(`Network Error: ${error.message}`); } } + +/** + * Validates connection and fetches all repositories accessible by the user. + */ +export async function authenticateAndFetchRepos( + url: string, + userid: string, + token: string +): Promise { + const normalized = normalizeUrl(url); + const headers: Record = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }; + + if (token.trim()) { + headers['Authorization'] = `token ${token.trim()}`; + } + + const client = axios.create({ + baseURL: `${normalized}/api/v1`, + headers, + timeout: 10000, + }); + + // 1. Verify credentials / user existence + try { + if (token.trim()) { + await client.get('/user'); + } else if (userid.trim()) { + await client.get(`/users/${userid.trim()}`); + } + } catch (error: any) { + if (error.response) { + if (error.response.status === 401) { + throw new Error('Unauthorized: Invalid API Token.'); + } + if (error.response.status === 404) { + throw new Error(`User "${userid}" not found.`); + } + throw new Error(`Authentication failed: ${error.response.data?.message || error.message}`); + } + throw new Error(`Failed to connect to ${normalized}: ${error.message}`); + } + + // 2. Fetch repositories + try { + let repos: any[] = []; + + if (token.trim()) { + // Fetch authenticated user's repos (lists all they have access to) + const response = await client.get('/user/repos', { params: { limit: 100 } }); + repos = response.data; + } else if (userid.trim()) { + // Fetch public repos of the target username + const response = await client.get(`/users/${userid.trim()}/repos`, { params: { limit: 100 } }); + repos = response.data; + } + + if (!Array.isArray(repos)) { + throw new Error('Invalid response from server (expected list of repositories).'); + } + + return repos.map((r: any) => ({ + id: r.id, + name: r.name, + full_name: r.full_name || `${r.owner?.login}/${r.name}`, + private: !!r.private, + description: r.description || '', + })); + } catch (error: any) { + if (error.response) { + throw new Error(`Failed to fetch repositories: ${error.response.data?.message || error.message}`); + } + throw new Error(`Failed to fetch repositories: ${error.message}`); + } +} + diff --git a/src/index.ts b/src/index.ts index 27b656e..06646c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ program .description('A premium CLI TUI Dashboard for exploring Gitea and Forgejo issues') .version('1.0.0') .option('-u, --url ', 'Forgejo/Gitea instance base URL (e.g. https://forgejo.freshbrewed.science)') + .option('-i, --userid ', 'Forgejo/Gitea user ID / username') .option('-r, --repo ', 'Repository path (e.g. owner/repo)') .option('-t, --token ', 'Personal Access Token (optional for public repositories)'); @@ -27,6 +28,7 @@ async function bootstrap() { config: { url: '', token: null, + userid: '', owner: '', repo: '', }, @@ -46,11 +48,15 @@ async function bootstrap() { selectedIssueComments: [], commentsLoading: false, detailScrollOffset: 0, + repos: [], + selectedRepoIndex: 0, + repoSearchQuery: '', + repoPickerActiveSearch: false, setupForm: { url: options.url || 'https://forgejo.freshbrewed.science', - repo: options.repo || '', + userid: options.userid || '', token: options.token || '', - activeField: options.url ? (options.repo ? 'token' : 'repo') : 'url', + activeField: options.url ? (options.userid ? 'token' : 'userid') : 'url', }, }; @@ -62,6 +68,7 @@ async function bootstrap() { const config: Config = { url: normalized, token: options.token ? options.token.trim() : null, + userid: options.userid || repoParts[0].trim(), owner: repoParts[0].trim(), repo: repoParts[1].trim(), }; @@ -79,7 +86,7 @@ async function bootstrap() { state.screen = 'setup'; state.setupForm = { url: options.url, - repo: options.repo, + userid: options.userid || '', token: options.token || '', activeField: 'url', }; diff --git a/src/tui.ts b/src/tui.ts index 0970d4b..2b4a2a9 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 } from './api.js'; +import { fetchIssues, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos } from './api.js'; // Setup readline for stdin keypress events readline.emitKeypressEvents(process.stdin); @@ -80,6 +80,14 @@ function truncate(str: string, length: number): string { return str.substring(0, length - 3) + '...'; } +/** + * 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 */ @@ -227,6 +235,8 @@ export class TuiEngine { 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); @@ -243,7 +253,7 @@ export class TuiEngine { */ private async handleSetupKeypress(str: string, key: any) { const form = this.state.setupForm; - const fields: Array<'url' | 'repo' | 'token'> = ['url', 'repo', 'token']; + const fields: Array<'url' | 'userid' | 'token'> = ['url', 'userid', 'token']; const currentIdx = fields.indexOf(form.activeField); if (key && key.name === 'down') { @@ -271,15 +281,8 @@ export class TuiEngine { this.render(); return; } - if (!form.repo.trim()) { - this.state.error = 'Repository path (owner/repo) is required.'; - this.render(); - return; - } - - const repoParts = form.repo.split('/'); - if (repoParts.length !== 2 || !repoParts[0].trim() || !repoParts[1].trim()) { - this.state.error = 'Repository must be in "owner/repo" format.'; + if (!form.userid.trim()) { + this.state.error = 'User ID / Username is required.'; this.render(); return; } @@ -288,22 +291,22 @@ export class TuiEngine { this.state.error = null; this.render(); - const normalizedUrl = normalizeUrl(form.url); - const testConfig = { - url: normalizedUrl, - token: form.token.trim() || null, - owner: repoParts[0].trim(), - repo: repoParts[1].trim(), - }; - try { - await validateConnection(testConfig); - // Save config - this.state.config = testConfig; - this.state.screen = 'list'; - this.state.currentPage = 1; - this.state.selectedIssueIndex = 0; - this.loadIssues(); + 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; @@ -319,7 +322,7 @@ export class TuiEngine { if (key && key.name === 'backspace') { if (form.activeField === 'url') form.url = form.url.slice(0, -1); - if (form.activeField === 'repo') form.repo = form.repo.slice(0, -1); + if (form.activeField === 'userid') form.userid = form.userid.slice(0, -1); if (form.activeField === 'token') form.token = form.token.slice(0, -1); this.render(); return; @@ -328,12 +331,107 @@ export class TuiEngine { // Type character if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) { if (form.activeField === 'url') form.url += str; - if (form.activeField === 'repo') form.repo += str; + if (form.activeField === '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') { + if (this.state.selectedRepoIndex > 0) { + this.state.selectedRepoIndex--; + this.render(); + } + return; + } + + if (key && key.name === 'down') { + if (this.state.selectedRepoIndex < filteredRepos.length - 1) { + this.state.selectedRepoIndex++; + this.render(); + } + return; + } + + if (str === '/') { + this.state.repoPickerActiveSearch = true; + this.render(); + return; + } + + if ((key && key.name === 'return') || str === '\r' || str === '\n') { + if (filteredRepos.length > 0 && this.state.selectedRepoIndex < filteredRepos.length) { + const selectedRepo = filteredRepos[this.state.selectedRepoIndex]; + const parts = selectedRepo.full_name.split('/'); + + this.state.config = { + url: normalizeUrl(this.state.setupForm.url), + token: this.state.setupForm.token.trim() || null, + userid: this.state.setupForm.userid.trim(), + owner: parts[0], + repo: parts[1], + }; + + this.state.screen = 'list'; + this.state.currentPage = 1; + this.state.selectedIssueIndex = 0; + this.state.searchQuery = ''; + this.loadIssues(); + } + } + } + + /** * Key handling for searching overlay */ @@ -375,14 +473,14 @@ export class TuiEngine { if (this.state.loading) return; if ((key && key.name === 'escape') || str === '\u001b') { - // If we entered config via CLI, quit. If we came from setup form, go back to setup! - if (this.state.setupForm.repo) { - this.state.screen = 'setup'; - this.state.error = null; - this.render(); - } else { - this.stop(); - } + this.stop(); + return; + } + + if (str === 'o' || str === 'O') { + this.state.screen = 'setup'; + this.state.error = null; + this.render(); return; } @@ -524,6 +622,15 @@ export class TuiEngine { 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 ((key && (key.name === 'escape' || key.name === 'backspace')) || str === '\u001b' || str === 'q' || str === 'Q') { this.state.screen = 'list'; this.state.selectedIssue = null; @@ -544,6 +651,8 @@ export class TuiEngine { 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') { @@ -591,8 +700,8 @@ export class TuiEngine { lines.push(renderField('Gitea URL', form.url, form.activeField === 'url')); lines.push(border + ' '.repeat(width - 2) + border); - lines.push(renderField('Repository', form.repo, form.activeField === 'repo')); - lines.push(border + ' ' + chalk.gray('Format: owner/repo (e.g. gitea/tea)').padEnd(width - 5) + border); + lines.push(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); @@ -627,8 +736,8 @@ export class TuiEngine { if (!this.state.loading) { // Find current active field offset row let activeRowOffset = vertPadding + 6; // URL row - if (form.activeField === 'repo') activeRowOffset += 2; - if (form.activeField === 'token') activeRowOffset += 4; + if (form.activeField === 'userid') activeRowOffset = vertPadding + 8; + if (form.activeField === 'token') activeRowOffset = vertPadding + 11; const activeValue = form.activeField === 'token' ? '*'.repeat(form.token.length) : form[form.activeField]; const cursorCol = Math.max(0, Math.floor((cols - width) / 2)) + 23 + activeValue.length; @@ -753,7 +862,7 @@ export class TuiEngine { console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘')); // Keyboard controls help line - const helpLine = chalk.gray(' [↑/↓] Navigate [Enter] View [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [Esc] Back'); + const helpLine = chalk.gray(' [↑/↓] Navigate [Enter] View [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [O] Settings [Esc] Quit'); process.stdout.write(helpLine); // If search active, position terminal cursor in search box @@ -854,7 +963,89 @@ export class TuiEngine { scrollHelp += chalk.yellow(` (${pct}%)`); } - const helpLine = chalk.gray(` [Esc/Backspace] Back to List ${scrollHelp}`); + const helpLine = chalk.gray(` [Esc/Backspace] Back to List [O] Settings ${scrollHelp}`); 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 + } + } } + diff --git a/src/types.ts b/src/types.ts index d9b0405..0df7734 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export interface Config { url: string; token: string | null; + userid: string; owner: string; repo: string; } @@ -41,12 +42,20 @@ export interface Comment { export interface SetupForm { url: string; + userid: string; token: string; - repo: string; - activeField: 'url' | 'token' | 'repo'; + activeField: 'url' | 'userid' | 'token'; } -export type ScreenType = 'setup' | 'list' | 'details'; +export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details'; + +export interface RepoItem { + id: number; + name: string; + full_name: string; + private: boolean; + description: string; +} export interface AppState { screen: ScreenType; @@ -72,6 +81,13 @@ export interface AppState { commentsLoading: boolean; detailScrollOffset: number; + // Repo Picker State + repos: RepoItem[]; + selectedRepoIndex: number; + repoSearchQuery: string; + repoPickerActiveSearch: boolean; + // Setup Form State setupForm: SetupForm; } +