diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b1fcb2c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,73 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +export interface SavedConfig { + url?: string; + userid?: string; + token?: string; + apiKey?: string; + repo?: string; + repository?: string; +} + +export const LOCAL_CONFIG_PATH = path.join(process.cwd(), 'fjtui.json'); +export const GLOBAL_CONFIG_PATH = path.join(os.homedir(), '.config', 'fjtui', 'fjtui.json'); + +/** + * Loads configuration from either local or global fjtui.json. + * Local configuration overrides global configuration. + */ +export function loadSavedConfig(): { url: string; userid: string; token: string; repo: string } { + let localConfig: SavedConfig = {}; + let globalConfig: SavedConfig = {}; + + if (fs.existsSync(LOCAL_CONFIG_PATH)) { + try { + const data = fs.readFileSync(LOCAL_CONFIG_PATH, 'utf-8'); + localConfig = JSON.parse(data); + } catch (e) { + // Ignore malformed files + } + } + + if (fs.existsSync(GLOBAL_CONFIG_PATH)) { + try { + const data = fs.readFileSync(GLOBAL_CONFIG_PATH, 'utf-8'); + globalConfig = JSON.parse(data); + } catch (e) { + // Ignore malformed files + } + } + + // Merge them (local overrides global) + const merged = { ...globalConfig, ...localConfig }; + + return { + url: merged.url || '', + userid: merged.userid || '', + token: merged.token || merged.apiKey || '', + repo: merged.repo || merged.repository || '', + }; +} + +/** + * Saves settings to the global configuration file. + */ +export function saveGlobalConfig(config: { url: string; userid: string; token: string | null; repo: string }) { + try { + const dir = path.dirname(GLOBAL_CONFIG_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const data = JSON.stringify({ + url: config.url, + userid: config.userid, + token: config.token || '', + repo: config.repo + }, null, 2); + fs.writeFileSync(GLOBAL_CONFIG_PATH, data, 'utf-8'); + } catch (e) { + // Ignore errors + } +} diff --git a/src/index.ts b/src/index.ts index 06646c5..0371a83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { Command } from 'commander'; import { AppState, Config } from './types.js'; import { TuiEngine } from './tui.js'; import { validateConnection, normalizeUrl } from './api.js'; +import { loadSavedConfig, saveGlobalConfig } from './config.js'; import chalk from 'chalk'; const program = new Command(); @@ -15,13 +16,21 @@ program .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)'); + .option('-t, --token ', 'Personal Access Token (optional for public repositories)') + .option('-s, --save', 'Save the provided settings to the global config file (~/.config/fjtui/fjtui.json)'); program.parse(process.argv); const options = program.opts(); async function bootstrap() { + const savedConfig = loadSavedConfig(); + + const url = options.url || savedConfig.url || ''; + const userid = options.userid || savedConfig.userid || ''; + const token = options.token || savedConfig.token || ''; + const repo = options.repo || savedConfig.repo || ''; + // Initialize default state const state: AppState = { screen: 'setup', @@ -53,22 +62,23 @@ async function bootstrap() { repoSearchQuery: '', repoPickerActiveSearch: false, setupForm: { - url: options.url || 'https://forgejo.freshbrewed.science', - userid: options.userid || '', - token: options.token || '', - activeField: options.url ? (options.userid ? 'token' : 'userid') : 'url', + url: url || 'https://forgejo.freshbrewed.science', + userid: userid || '', + token: token || '', + saveConfig: true, + activeField: url ? (userid ? 'token' : 'userid') : 'url', }, }; // If parameters are provided, try direct connection first - if (options.url && options.repo) { - const repoParts = options.repo.split('/'); + if (url && repo) { + const repoParts = repo.split('/'); if (repoParts.length === 2 && repoParts[0].trim() && repoParts[1].trim()) { - const normalized = normalizeUrl(options.url); + const normalized = normalizeUrl(url); const config: Config = { url: normalized, - token: options.token ? options.token.trim() : null, - userid: options.userid || repoParts[0].trim(), + token: token ? token.trim() : null, + userid: userid || repoParts[0].trim(), owner: repoParts[0].trim(), repo: repoParts[1].trim(), }; @@ -80,20 +90,39 @@ async function bootstrap() { // Valid connection! Go straight to list screen state.config = config; state.screen = 'list'; + + // Optionally save to global config if --save option is provided + if (options.save) { + saveGlobalConfig({ + url: normalized, + userid: config.userid, + token: config.token, + repo: repo, + }); + console.log(chalk.green('Settings saved successfully to global config file!')); + } } catch (err: any) { // Validation failed, let's load setup form with the entered values and show error! state.error = `Connection failed: ${err.message}`; state.screen = 'setup'; state.setupForm = { - url: options.url, - userid: options.userid || '', - token: options.token || '', + url: url, + userid: userid || '', + token: token || '', + saveConfig: true, activeField: 'url', }; } } else { - state.error = 'Invalid --repo option. Must be in owner/repo format.'; + state.error = 'Invalid repository path. Must be in owner/repo format.'; state.screen = 'setup'; + state.setupForm = { + url: url, + userid: userid || '', + token: token || '', + saveConfig: true, + activeField: 'url', + }; } } diff --git a/src/tui.ts b/src/tui.ts index 2b4a2a9..67c2276 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -2,6 +2,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 { saveGlobalConfig } from './config.js'; // Setup readline for stdin keypress events readline.emitKeypressEvents(process.stdin); @@ -253,7 +254,7 @@ export class TuiEngine { */ private async handleSetupKeypress(str: string, key: any) { const form = this.state.setupForm; - const fields: Array<'url' | 'userid' | 'token'> = ['url', 'userid', 'token']; + const fields: Array<'url' | 'userid' | 'token' | 'saveConfig'> = ['url', 'userid', 'token', 'saveConfig']; const currentIdx = fields.indexOf(form.activeField); if (key && key.name === 'down') { @@ -274,7 +275,21 @@ export class TuiEngine { 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.'; @@ -421,6 +436,15 @@ export class TuiEngine { 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; @@ -698,6 +722,14 @@ export class TuiEngine { return border + ' ' + activeMarker + coloredContent.padEnd(width - 6) + ' ' + border; }; + const renderCheckbox = (label: string, checked: boolean, active: boolean) => { + const activeMarker = active ? chalk.cyan('▶ ') : ' '; + const box = checked ? '[X]' : '[ ]'; + const content = label.padEnd(16) + ': ' + (active ? chalk.bold.cyan(box) : chalk.white(box)); + const coloredContent = active ? chalk.bold.white(content) : chalk.gray(content); + return border + ' ' + activeMarker + coloredContent.padEnd(width - 6) + ' ' + border; + }; + 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')); @@ -706,6 +738,9 @@ export class TuiEngine { 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) { @@ -720,7 +755,7 @@ export class TuiEngine { // Loading / Footer indicator const spinner = this.state.loading ? chalk.cyan(SPINNER_FRAMES[spinnerIndex]) : ' '; - const prompt = this.state.loading ? ' Connecting to instance...' : ' [Tab/Arrows] Navigate [Enter] Connect [Esc] Quit'; + 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)); @@ -738,9 +773,15 @@ export class TuiEngine { 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; - 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; + 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 } @@ -754,15 +795,22 @@ export class TuiEngine { const spinnerStr = this.state.loading ? chalk.bold.cyan(SPINNER_FRAMES[spinnerIndex]) + ' ' : ''; const instanceName = normalizeUrl(this.state.config.url).replace(/^https?:\/\//, ''); - let header = ` ${spinnerStr}${chalk.bold.hex('#4A90E2')('Forgejo Issue Explorer')} ─ ${chalk.bold.white(instanceName)} ─ repo: ${chalk.bold.cyan(`${this.state.config.owner}/${this.state.config.repo}`)}`; + let leftHeader = ` ${spinnerStr}${chalk.bold.hex('#4A90E2')('Forgejo Issue Explorer')} ─ ${chalk.bold.white(instanceName)}`; if (this.state.totalIssuesCount > 0) { const totalPages = Math.ceil(this.state.totalIssuesCount / this.state.issuesPerPage); - header += ` ─ Page ${chalk.bold.white(this.state.currentPage)} of ${chalk.bold.white(totalPages)} (${chalk.yellow(this.state.totalIssuesCount)} matching)`; + leftHeader += ` ─ Page ${chalk.bold.white(this.state.currentPage)} of ${chalk.bold.white(totalPages)} (${chalk.yellow(this.state.totalIssuesCount)} matching)`; } else if (!this.state.loading) { - header += ' ─ (0 issues found)'; + leftHeader += ' ─ (0 issues found)'; } - console.log(header); + const rightHeader = `repo: ${chalk.bold.cyan(`${this.state.config.owner}/${this.state.config.repo}`)} `; + + const leftLen = stripAnsi(leftHeader).length; + const rightLen = stripAnsi(rightHeader).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) + '┐')); // Column widths @@ -882,7 +930,21 @@ export class TuiEngine { // Header info line const isPR = !!issue.pull_request; const typeTag = isPR ? chalk.bold.magenta('[PR] ') : chalk.bold.green('[Issue] '); - console.log(` ${typeTag}#${issue.number} ─ ${chalk.bold.white(truncate(issue.title, cols - 16))}`); + 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 diff --git a/src/types.ts b/src/types.ts index 0df7734..e087dd4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,7 +44,8 @@ export interface SetupForm { url: string; userid: string; token: string; - activeField: 'url' | 'userid' | 'token'; + saveConfig: boolean; + activeField: 'url' | 'userid' | 'token' | 'saveConfig'; } export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details';