settings saved
This commit is contained in:
parent
506700caf9
commit
64f147d10c
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/index.ts
57
src/index.ts
|
|
@ -4,6 +4,7 @@ import { Command } from 'commander';
|
||||||
import { AppState, Config } from './types.js';
|
import { AppState, Config } from './types.js';
|
||||||
import { TuiEngine } from './tui.js';
|
import { TuiEngine } from './tui.js';
|
||||||
import { validateConnection, normalizeUrl } from './api.js';
|
import { validateConnection, normalizeUrl } from './api.js';
|
||||||
|
import { loadSavedConfig, saveGlobalConfig } from './config.js';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
|
||||||
const program = new Command();
|
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('-u, --url <url>', 'Forgejo/Gitea instance base URL (e.g. https://forgejo.freshbrewed.science)')
|
||||||
.option('-i, --userid <userid>', 'Forgejo/Gitea user ID / username')
|
.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)')
|
||||||
|
.option('-s, --save', 'Save the provided settings to the global config file (~/.config/fjtui/fjtui.json)');
|
||||||
|
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
||||||
|
|
||||||
const options = program.opts();
|
const options = program.opts();
|
||||||
|
|
||||||
async function bootstrap() {
|
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
|
// Initialize default state
|
||||||
const state: AppState = {
|
const state: AppState = {
|
||||||
screen: 'setup',
|
screen: 'setup',
|
||||||
|
|
@ -53,22 +62,23 @@ async function bootstrap() {
|
||||||
repoSearchQuery: '',
|
repoSearchQuery: '',
|
||||||
repoPickerActiveSearch: false,
|
repoPickerActiveSearch: false,
|
||||||
setupForm: {
|
setupForm: {
|
||||||
url: options.url || 'https://forgejo.freshbrewed.science',
|
url: url || 'https://forgejo.freshbrewed.science',
|
||||||
userid: options.userid || '',
|
userid: userid || '',
|
||||||
token: options.token || '',
|
token: token || '',
|
||||||
activeField: options.url ? (options.userid ? 'token' : 'userid') : 'url',
|
saveConfig: true,
|
||||||
|
activeField: url ? (userid ? 'token' : 'userid') : 'url',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// If parameters are provided, try direct connection first
|
// If parameters are provided, try direct connection first
|
||||||
if (options.url && options.repo) {
|
if (url && repo) {
|
||||||
const repoParts = options.repo.split('/');
|
const repoParts = repo.split('/');
|
||||||
if (repoParts.length === 2 && repoParts[0].trim() && repoParts[1].trim()) {
|
if (repoParts.length === 2 && repoParts[0].trim() && repoParts[1].trim()) {
|
||||||
const normalized = normalizeUrl(options.url);
|
const normalized = normalizeUrl(url);
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
url: normalized,
|
url: normalized,
|
||||||
token: options.token ? options.token.trim() : null,
|
token: token ? token.trim() : null,
|
||||||
userid: options.userid || repoParts[0].trim(),
|
userid: userid || repoParts[0].trim(),
|
||||||
owner: repoParts[0].trim(),
|
owner: repoParts[0].trim(),
|
||||||
repo: repoParts[1].trim(),
|
repo: repoParts[1].trim(),
|
||||||
};
|
};
|
||||||
|
|
@ -80,20 +90,39 @@ async function bootstrap() {
|
||||||
// Valid connection! Go straight to list screen
|
// Valid connection! Go straight to list screen
|
||||||
state.config = config;
|
state.config = config;
|
||||||
state.screen = 'list';
|
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) {
|
} catch (err: any) {
|
||||||
// Validation failed, let's load setup form with the entered values and show error!
|
// Validation failed, let's load setup form with the entered values and show error!
|
||||||
state.error = `Connection failed: ${err.message}`;
|
state.error = `Connection failed: ${err.message}`;
|
||||||
state.screen = 'setup';
|
state.screen = 'setup';
|
||||||
state.setupForm = {
|
state.setupForm = {
|
||||||
url: options.url,
|
url: url,
|
||||||
userid: options.userid || '',
|
userid: userid || '',
|
||||||
token: options.token || '',
|
token: token || '',
|
||||||
|
saveConfig: true,
|
||||||
activeField: 'url',
|
activeField: 'url',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} 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.screen = 'setup';
|
||||||
|
state.setupForm = {
|
||||||
|
url: url,
|
||||||
|
userid: userid || '',
|
||||||
|
token: token || '',
|
||||||
|
saveConfig: true,
|
||||||
|
activeField: 'url',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
80
src/tui.ts
80
src/tui.ts
|
|
@ -2,6 +2,7 @@ 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, authenticateAndFetchRepos } from './api.js';
|
import { fetchIssues, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos } from './api.js';
|
||||||
|
import { saveGlobalConfig } from './config.js';
|
||||||
|
|
||||||
// Setup readline for stdin keypress events
|
// Setup readline for stdin keypress events
|
||||||
readline.emitKeypressEvents(process.stdin);
|
readline.emitKeypressEvents(process.stdin);
|
||||||
|
|
@ -253,7 +254,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' | 'userid' | 'token'> = ['url', 'userid', 'token'];
|
const fields: Array<'url' | 'userid' | 'token' | 'saveConfig'> = ['url', 'userid', 'token', 'saveConfig'];
|
||||||
const currentIdx = fields.indexOf(form.activeField);
|
const currentIdx = fields.indexOf(form.activeField);
|
||||||
|
|
||||||
if (key && key.name === 'down') {
|
if (key && key.name === 'down') {
|
||||||
|
|
@ -274,7 +275,21 @@ export class TuiEngine {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str === ' ') {
|
||||||
|
if (form.activeField === 'saveConfig') {
|
||||||
|
form.saveConfig = !form.saveConfig;
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
|
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
|
||||||
|
if (form.activeField === 'saveConfig') {
|
||||||
|
form.saveConfig = !form.saveConfig;
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Submit form
|
// Submit form
|
||||||
if (!form.url.trim()) {
|
if (!form.url.trim()) {
|
||||||
this.state.error = 'Instance URL is required.';
|
this.state.error = 'Instance URL is required.';
|
||||||
|
|
@ -422,6 +437,15 @@ export class TuiEngine {
|
||||||
repo: parts[1],
|
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.screen = 'list';
|
||||||
this.state.currentPage = 1;
|
this.state.currentPage = 1;
|
||||||
this.state.selectedIssueIndex = 0;
|
this.state.selectedIssueIndex = 0;
|
||||||
|
|
@ -698,6 +722,14 @@ export class TuiEngine {
|
||||||
return border + ' ' + activeMarker + coloredContent.padEnd(width - 6) + ' ' + border;
|
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(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('User ID', form.userid, form.activeField === 'userid'));
|
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(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);
|
||||||
lines.push(border + ' '.repeat(width - 2) + 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
|
// Error message
|
||||||
if (this.state.error) {
|
if (this.state.error) {
|
||||||
|
|
@ -720,7 +755,7 @@ export class TuiEngine {
|
||||||
|
|
||||||
// Loading / Footer indicator
|
// Loading / Footer indicator
|
||||||
const spinner = this.state.loading ? chalk.cyan(SPINNER_FRAMES[spinnerIndex]) : ' ';
|
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
|
// Draw centered on terminal
|
||||||
const vertPadding = Math.max(0, Math.floor((rows - lines.length - 2) / 2));
|
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
|
let activeRowOffset = vertPadding + 6; // URL row
|
||||||
if (form.activeField === 'userid') activeRowOffset = vertPadding + 8;
|
if (form.activeField === 'userid') activeRowOffset = vertPadding + 8;
|
||||||
if (form.activeField === 'token') activeRowOffset = vertPadding + 11;
|
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];
|
let cursorCol = Math.max(0, Math.floor((cols - width) / 2));
|
||||||
const cursorCol = Math.max(0, Math.floor((cols - width) / 2)) + 23 + activeValue.length;
|
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
|
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 spinnerStr = this.state.loading ? chalk.bold.cyan(SPINNER_FRAMES[spinnerIndex]) + ' ' : '';
|
||||||
const instanceName = normalizeUrl(this.state.config.url).replace(/^https?:\/\//, '');
|
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) {
|
if (this.state.totalIssuesCount > 0) {
|
||||||
const totalPages = Math.ceil(this.state.totalIssuesCount / this.state.issuesPerPage);
|
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) {
|
} 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) + '┐'));
|
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
|
||||||
|
|
||||||
// Column widths
|
// Column widths
|
||||||
|
|
@ -882,7 +930,21 @@ export class TuiEngine {
|
||||||
// Header info line
|
// Header info line
|
||||||
const isPR = !!issue.pull_request;
|
const isPR = !!issue.pull_request;
|
||||||
const typeTag = isPR ? chalk.bold.magenta('[PR] ') : chalk.bold.green('[Issue] ');
|
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) + '┐'));
|
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
|
||||||
|
|
||||||
// Metadata lines
|
// Metadata lines
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ export interface SetupForm {
|
||||||
url: string;
|
url: string;
|
||||||
userid: string;
|
userid: string;
|
||||||
token: string;
|
token: string;
|
||||||
activeField: 'url' | 'userid' | 'token';
|
saveConfig: boolean;
|
||||||
|
activeField: 'url' | 'userid' | 'token' | 'saveConfig';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details';
|
export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue