settings saved

This commit is contained in:
Isaac Johnson 2026-05-31 21:27:57 -05:00
parent 506700caf9
commit 64f147d10c
4 changed files with 189 additions and 24 deletions

73
src/config.ts Normal file
View File

@ -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
}
}

View File

@ -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 <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('-t, --token <token>', 'Personal Access Token (optional for public repositories)');
.option('-t, --token <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',
};
}
}

View File

@ -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.';
@ -422,6 +437,15 @@ export class TuiEngine {
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;
@ -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

View File

@ -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';