login and settings
This commit is contained in:
parent
e35f9ad95e
commit
506700caf9
80
src/api.ts
80
src/api.ts
|
|
@ -1,5 +1,5 @@
|
||||||
import axios from 'axios';
|
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
|
* Normalizes the Gitea/Forgejo URL by stripping trailing slashes
|
||||||
|
|
@ -170,3 +170,81 @@ export async function fetchIssueComments(
|
||||||
throw new Error(`Network Error: ${error.message}`);
|
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<RepoItem[]> {
|
||||||
|
const normalized = normalizeUrl(url);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
13
src/index.ts
13
src/index.ts
|
|
@ -13,6 +13,7 @@ program
|
||||||
.description('A premium CLI TUI Dashboard for exploring Gitea and Forgejo issues')
|
.description('A premium CLI TUI Dashboard for exploring Gitea and Forgejo issues')
|
||||||
.version('1.0.0')
|
.version('1.0.0')
|
||||||
.option('-u, --url <url>', 'Forgejo/Gitea instance base URL (e.g. https://forgejo.freshbrewed.science)')
|
.option('-u, --url <url>', 'Forgejo/Gitea instance base URL (e.g. https://forgejo.freshbrewed.science)')
|
||||||
|
.option('-i, --userid <userid>', 'Forgejo/Gitea user ID / username')
|
||||||
.option('-r, --repo <owner/repo>', 'Repository path (e.g. owner/repo)')
|
.option('-r, --repo <owner/repo>', 'Repository path (e.g. owner/repo)')
|
||||||
.option('-t, --token <token>', 'Personal Access Token (optional for public repositories)');
|
.option('-t, --token <token>', 'Personal Access Token (optional for public repositories)');
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ async function bootstrap() {
|
||||||
config: {
|
config: {
|
||||||
url: '',
|
url: '',
|
||||||
token: null,
|
token: null,
|
||||||
|
userid: '',
|
||||||
owner: '',
|
owner: '',
|
||||||
repo: '',
|
repo: '',
|
||||||
},
|
},
|
||||||
|
|
@ -46,11 +48,15 @@ async function bootstrap() {
|
||||||
selectedIssueComments: [],
|
selectedIssueComments: [],
|
||||||
commentsLoading: false,
|
commentsLoading: false,
|
||||||
detailScrollOffset: 0,
|
detailScrollOffset: 0,
|
||||||
|
repos: [],
|
||||||
|
selectedRepoIndex: 0,
|
||||||
|
repoSearchQuery: '',
|
||||||
|
repoPickerActiveSearch: false,
|
||||||
setupForm: {
|
setupForm: {
|
||||||
url: options.url || 'https://forgejo.freshbrewed.science',
|
url: options.url || 'https://forgejo.freshbrewed.science',
|
||||||
repo: options.repo || '',
|
userid: options.userid || '',
|
||||||
token: options.token || '',
|
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 = {
|
const config: Config = {
|
||||||
url: normalized,
|
url: normalized,
|
||||||
token: options.token ? options.token.trim() : null,
|
token: options.token ? options.token.trim() : null,
|
||||||
|
userid: options.userid || repoParts[0].trim(),
|
||||||
owner: repoParts[0].trim(),
|
owner: repoParts[0].trim(),
|
||||||
repo: repoParts[1].trim(),
|
repo: repoParts[1].trim(),
|
||||||
};
|
};
|
||||||
|
|
@ -79,7 +86,7 @@ async function bootstrap() {
|
||||||
state.screen = 'setup';
|
state.screen = 'setup';
|
||||||
state.setupForm = {
|
state.setupForm = {
|
||||||
url: options.url,
|
url: options.url,
|
||||||
repo: options.repo,
|
userid: options.userid || '',
|
||||||
token: options.token || '',
|
token: options.token || '',
|
||||||
activeField: 'url',
|
activeField: 'url',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
275
src/tui.ts
275
src/tui.ts
|
|
@ -1,7 +1,7 @@
|
||||||
import readline from 'readline';
|
import readline from 'readline';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { AppState, Issue, Comment } from './types.js';
|
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
|
// Setup readline for stdin keypress events
|
||||||
readline.emitKeypressEvents(process.stdin);
|
readline.emitKeypressEvents(process.stdin);
|
||||||
|
|
@ -80,6 +80,14 @@ function truncate(str: string, length: number): string {
|
||||||
return str.substring(0, length - 3) + '...';
|
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
|
* TUI State Controller and Render Engine
|
||||||
*/
|
*/
|
||||||
|
|
@ -227,6 +235,8 @@ export class TuiEngine {
|
||||||
|
|
||||||
if (this.state.screen === 'setup') {
|
if (this.state.screen === 'setup') {
|
||||||
this.handleSetupKeypress(str, key);
|
this.handleSetupKeypress(str, key);
|
||||||
|
} else if (this.state.screen === 'repo-picker') {
|
||||||
|
this.handleRepoPickerKeypress(str, key);
|
||||||
} else if (this.state.screen === 'list') {
|
} else if (this.state.screen === 'list') {
|
||||||
if (this.activeSearchInput) {
|
if (this.activeSearchInput) {
|
||||||
this.handleSearchKeypress(str, key);
|
this.handleSearchKeypress(str, key);
|
||||||
|
|
@ -243,7 +253,7 @@ export class TuiEngine {
|
||||||
*/
|
*/
|
||||||
private async handleSetupKeypress(str: string, key: any) {
|
private async handleSetupKeypress(str: string, key: any) {
|
||||||
const form = this.state.setupForm;
|
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);
|
const currentIdx = fields.indexOf(form.activeField);
|
||||||
|
|
||||||
if (key && key.name === 'down') {
|
if (key && key.name === 'down') {
|
||||||
|
|
@ -271,15 +281,8 @@ export class TuiEngine {
|
||||||
this.render();
|
this.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!form.repo.trim()) {
|
if (!form.userid.trim()) {
|
||||||
this.state.error = 'Repository path (owner/repo) is required.';
|
this.state.error = 'User ID / Username is required.';
|
||||||
this.render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoParts = form.repo.split('/');
|
|
||||||
if (repoParts.length !== 2 || !repoParts[0].trim() || !repoParts[1].trim()) {
|
|
||||||
this.state.error = 'Repository must be in "owner/repo" format.';
|
|
||||||
this.render();
|
this.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -288,22 +291,22 @@ export class TuiEngine {
|
||||||
this.state.error = null;
|
this.state.error = null;
|
||||||
this.render();
|
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 {
|
try {
|
||||||
await validateConnection(testConfig);
|
const repos = await authenticateAndFetchRepos(form.url, form.userid, form.token);
|
||||||
// Save config
|
|
||||||
this.state.config = testConfig;
|
this.state.repos = repos;
|
||||||
this.state.screen = 'list';
|
this.state.selectedRepoIndex = 0;
|
||||||
this.state.currentPage = 1;
|
this.state.repoSearchQuery = '';
|
||||||
this.state.selectedIssueIndex = 0;
|
this.state.repoPickerActiveSearch = false;
|
||||||
this.loadIssues();
|
|
||||||
|
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) {
|
} catch (err: any) {
|
||||||
this.state.error = err.message;
|
this.state.error = err.message;
|
||||||
this.state.loading = false;
|
this.state.loading = false;
|
||||||
|
|
@ -319,7 +322,7 @@ export class TuiEngine {
|
||||||
|
|
||||||
if (key && key.name === 'backspace') {
|
if (key && key.name === 'backspace') {
|
||||||
if (form.activeField === 'url') form.url = form.url.slice(0, -1);
|
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);
|
if (form.activeField === 'token') form.token = form.token.slice(0, -1);
|
||||||
this.render();
|
this.render();
|
||||||
return;
|
return;
|
||||||
|
|
@ -328,12 +331,107 @@ export class TuiEngine {
|
||||||
// Type character
|
// Type character
|
||||||
if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) {
|
if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) {
|
||||||
if (form.activeField === 'url') form.url += str;
|
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;
|
if (form.activeField === 'token') form.token += str;
|
||||||
this.render();
|
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
|
* Key handling for searching overlay
|
||||||
*/
|
*/
|
||||||
|
|
@ -375,14 +473,14 @@ export class TuiEngine {
|
||||||
if (this.state.loading) return;
|
if (this.state.loading) return;
|
||||||
|
|
||||||
if ((key && key.name === 'escape') || str === '\u001b') {
|
if ((key && key.name === 'escape') || str === '\u001b') {
|
||||||
// If we entered config via CLI, quit. If we came from setup form, go back to setup!
|
this.stop();
|
||||||
if (this.state.setupForm.repo) {
|
return;
|
||||||
this.state.screen = 'setup';
|
}
|
||||||
this.state.error = null;
|
|
||||||
this.render();
|
if (str === 'o' || str === 'O') {
|
||||||
} else {
|
this.state.screen = 'setup';
|
||||||
this.stop();
|
this.state.error = null;
|
||||||
}
|
this.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -524,6 +622,15 @@ export class TuiEngine {
|
||||||
return;
|
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') {
|
if ((key && (key.name === 'escape' || key.name === 'backspace')) || str === '\u001b' || str === 'q' || str === 'Q') {
|
||||||
this.state.screen = 'list';
|
this.state.screen = 'list';
|
||||||
this.state.selectedIssue = null;
|
this.state.selectedIssue = null;
|
||||||
|
|
@ -544,6 +651,8 @@ export class TuiEngine {
|
||||||
|
|
||||||
if (this.state.screen === 'setup') {
|
if (this.state.screen === 'setup') {
|
||||||
this.renderSetupScreen(cols, rows);
|
this.renderSetupScreen(cols, rows);
|
||||||
|
} else if (this.state.screen === 'repo-picker') {
|
||||||
|
this.renderRepoPickerScreen(cols, rows);
|
||||||
} else if (this.state.screen === 'list') {
|
} else if (this.state.screen === 'list') {
|
||||||
this.renderListScreen(cols, rows);
|
this.renderListScreen(cols, rows);
|
||||||
} else if (this.state.screen === 'details') {
|
} 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(renderField('Gitea URL', form.url, form.activeField === 'url'));
|
||||||
lines.push(border + ' '.repeat(width - 2) + border);
|
lines.push(border + ' '.repeat(width - 2) + border);
|
||||||
lines.push(renderField('Repository', form.repo, form.activeField === 'repo'));
|
lines.push(renderField('User ID', form.userid, form.activeField === 'userid'));
|
||||||
lines.push(border + ' ' + chalk.gray('Format: owner/repo (e.g. gitea/tea)').padEnd(width - 5) + border);
|
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(border + ' '.repeat(width - 2) + border);
|
||||||
lines.push(renderField('Access Token', form.token, form.activeField === 'token', true));
|
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 + ' ' + chalk.gray('Optional. Required for private repos.').padEnd(width - 5) + border);
|
||||||
|
|
@ -627,8 +736,8 @@ export class TuiEngine {
|
||||||
if (!this.state.loading) {
|
if (!this.state.loading) {
|
||||||
// Find current active field offset row
|
// Find current active field offset row
|
||||||
let activeRowOffset = vertPadding + 6; // URL row
|
let activeRowOffset = vertPadding + 6; // URL row
|
||||||
if (form.activeField === 'repo') activeRowOffset += 2;
|
if (form.activeField === 'userid') activeRowOffset = vertPadding + 8;
|
||||||
if (form.activeField === 'token') activeRowOffset += 4;
|
if (form.activeField === 'token') activeRowOffset = vertPadding + 11;
|
||||||
|
|
||||||
const activeValue = form.activeField === 'token' ? '*'.repeat(form.token.length) : form[form.activeField];
|
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;
|
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) + '┘'));
|
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
|
||||||
|
|
||||||
// Keyboard controls help line
|
// 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);
|
process.stdout.write(helpLine);
|
||||||
|
|
||||||
// If search active, position terminal cursor in search box
|
// If search active, position terminal cursor in search box
|
||||||
|
|
@ -854,7 +963,89 @@ export class TuiEngine {
|
||||||
scrollHelp += chalk.yellow(` (${pct}%)`);
|
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);
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
22
src/types.ts
22
src/types.ts
|
|
@ -1,6 +1,7 @@
|
||||||
export interface Config {
|
export interface Config {
|
||||||
url: string;
|
url: string;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
userid: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
}
|
}
|
||||||
|
|
@ -41,12 +42,20 @@ export interface Comment {
|
||||||
|
|
||||||
export interface SetupForm {
|
export interface SetupForm {
|
||||||
url: string;
|
url: string;
|
||||||
|
userid: string;
|
||||||
token: string;
|
token: string;
|
||||||
repo: string;
|
activeField: 'url' | 'userid' | 'token';
|
||||||
activeField: 'url' | 'token' | 'repo';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export interface AppState {
|
||||||
screen: ScreenType;
|
screen: ScreenType;
|
||||||
|
|
@ -72,6 +81,13 @@ export interface AppState {
|
||||||
commentsLoading: boolean;
|
commentsLoading: boolean;
|
||||||
detailScrollOffset: number;
|
detailScrollOffset: number;
|
||||||
|
|
||||||
|
// Repo Picker State
|
||||||
|
repos: RepoItem[];
|
||||||
|
selectedRepoIndex: number;
|
||||||
|
repoSearchQuery: string;
|
||||||
|
repoPickerActiveSearch: boolean;
|
||||||
|
|
||||||
// Setup Form State
|
// Setup Form State
|
||||||
setupForm: SetupForm;
|
setupForm: SetupForm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue