add issue lables
This commit is contained in:
parent
616685c9b2
commit
e70afe6c05
104
src/api.ts
104
src/api.ts
|
|
@ -1,5 +1,5 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Config, Issue, Comment, RepoItem } from './types.js';
|
import { Config, Issue, Comment, RepoItem, Label } from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes the Gitea/Forgejo URL by stripping trailing slashes
|
* Normalizes the Gitea/Forgejo URL by stripping trailing slashes
|
||||||
|
|
@ -598,3 +598,105 @@ export async function setIssueAssignees(
|
||||||
throw new Error(`Network Error: ${error.message}`);
|
throw new Error(`Network Error: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all labels for a specific repository.
|
||||||
|
*/
|
||||||
|
export async function fetchLabels(config: Config): Promise<Label[]> {
|
||||||
|
const client = createAxiosInstance(config);
|
||||||
|
try {
|
||||||
|
const response = await client.get(`/repos/${config.owner}/${config.repo}/labels`);
|
||||||
|
return response.data.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
color: item.color,
|
||||||
|
description: item.description,
|
||||||
|
exclusive: item.exclusive,
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response) {
|
||||||
|
throw new Error(`Failed to fetch labels: ${error.response.data?.message || error.message}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Network Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new label in the specified repository.
|
||||||
|
*/
|
||||||
|
export async function createLabel(config: Config, label: Partial<Label>): Promise<Label> {
|
||||||
|
const client = createAxiosInstance(config);
|
||||||
|
try {
|
||||||
|
const response = await client.post(`/repos/${config.owner}/${config.repo}/labels`, {
|
||||||
|
name: label.name,
|
||||||
|
color: label.color, // expects hex without #
|
||||||
|
description: label.description,
|
||||||
|
exclusive: label.exclusive,
|
||||||
|
});
|
||||||
|
const item = response.data;
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
color: item.color,
|
||||||
|
description: item.description,
|
||||||
|
exclusive: item.exclusive,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response) {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
throw new Error('Unauthorized: You must be logged in with a token to create a label.');
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to create label: ${error.response.data?.message || error.message}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Network Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing label.
|
||||||
|
*/
|
||||||
|
export async function updateLabel(config: Config, id: number, label: Partial<Label>): Promise<Label> {
|
||||||
|
const client = createAxiosInstance(config);
|
||||||
|
try {
|
||||||
|
const response = await client.patch(`/repos/${config.owner}/${config.repo}/labels/${id}`, {
|
||||||
|
name: label.name,
|
||||||
|
color: label.color,
|
||||||
|
description: label.description,
|
||||||
|
exclusive: label.exclusive,
|
||||||
|
});
|
||||||
|
const item = response.data;
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
color: item.color,
|
||||||
|
description: item.description,
|
||||||
|
exclusive: item.exclusive,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response) {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
throw new Error('Unauthorized: You must be logged in with a token to update a label.');
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to update label: ${error.response.data?.message || error.message}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Network Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a label.
|
||||||
|
*/
|
||||||
|
export async function deleteLabel(config: Config, id: number): Promise<void> {
|
||||||
|
const client = createAxiosInstance(config);
|
||||||
|
try {
|
||||||
|
await client.delete(`/repos/${config.owner}/${config.repo}/labels/${id}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response) {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
throw new Error('Unauthorized: You must be logged in with a token to delete a label.');
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to delete label: ${error.response.data?.message || error.message}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Network Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
10
src/index.ts
10
src/index.ts
|
|
@ -85,6 +85,16 @@ async function bootstrap() {
|
||||||
addTimeForm: {
|
addTimeForm: {
|
||||||
timeInput: '',
|
timeInput: '',
|
||||||
},
|
},
|
||||||
|
labels: [],
|
||||||
|
selectedLabelIndex: 0,
|
||||||
|
labelsLoading: false,
|
||||||
|
labelForm: {
|
||||||
|
name: '',
|
||||||
|
color: '',
|
||||||
|
description: '',
|
||||||
|
exclusive: false,
|
||||||
|
activeField: 'name',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// If parameters are provided, try direct connection first
|
// If parameters are provided, try direct connection first
|
||||||
|
|
|
||||||
337
src/tui.ts
337
src/tui.ts
|
|
@ -4,7 +4,7 @@ import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { AppState, Issue, Comment } from './types.js';
|
import { AppState, Issue, Comment } from './types.js';
|
||||||
import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue, addIssueTime, changeIssueState, setIssueAssignees } from './api.js';
|
import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue, addIssueTime, changeIssueState, setIssueAssignees, fetchLabels, createLabel, updateLabel, deleteLabel } from './api.js';
|
||||||
import { saveGlobalConfig } from './config.js';
|
import { saveGlobalConfig } from './config.js';
|
||||||
|
|
||||||
// Setup readline for stdin keypress events
|
// Setup readline for stdin keypress events
|
||||||
|
|
@ -400,10 +400,12 @@ export class TuiEngine {
|
||||||
this.handleEditIssueKeypress(str, key);
|
this.handleEditIssueKeypress(str, key);
|
||||||
} else if (this.state.screen === 'add-time') {
|
} else if (this.state.screen === 'add-time') {
|
||||||
this.handleAddTimeKeypress(str, key);
|
this.handleAddTimeKeypress(str, key);
|
||||||
} else if (this.state.screen === 'confirm-state-change') {
|
|
||||||
this.handleConfirmStateChangeKeypress(str, key);
|
|
||||||
} else if (this.state.screen === 'set-assignees') {
|
} else if (this.state.screen === 'set-assignees') {
|
||||||
this.handleSetAssigneesKeypress(str, key);
|
this.handleSetAssigneesKeypress(str, key);
|
||||||
|
} else if (this.state.screen === 'labels-list') {
|
||||||
|
this.handleLabelsListKeypress(str, key);
|
||||||
|
} else if (this.state.screen === 'create-label' || this.state.screen === 'edit-label') {
|
||||||
|
this.handleLabelFormKeypress(str, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -778,6 +780,13 @@ export class TuiEngine {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str === 'l' || str === 'L') {
|
||||||
|
this.state.screen = 'labels-list';
|
||||||
|
this.state.selectedLabelIndex = 0;
|
||||||
|
this.loadLabels();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (str === 'q' || str === 'Q') {
|
if (str === 'q' || str === 'Q') {
|
||||||
this.stop();
|
this.stop();
|
||||||
}
|
}
|
||||||
|
|
@ -923,6 +932,10 @@ export class TuiEngine {
|
||||||
this.renderAnimationScreen(cols, rows, ZOMBIE_FRAMES);
|
this.renderAnimationScreen(cols, rows, ZOMBIE_FRAMES);
|
||||||
} else if (this.state.screen === 'set-assignees') {
|
} else if (this.state.screen === 'set-assignees') {
|
||||||
this.renderSetAssigneesScreen(cols, rows);
|
this.renderSetAssigneesScreen(cols, rows);
|
||||||
|
} else if (this.state.screen === 'labels-list') {
|
||||||
|
this.renderLabelsList(cols, rows);
|
||||||
|
} else if (this.state.screen === 'create-label' || this.state.screen === 'edit-label') {
|
||||||
|
this.renderLabelForm(cols, rows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1122,11 +1135,12 @@ export class TuiEngine {
|
||||||
const stateWidth = 7;
|
const stateWidth = 7;
|
||||||
const authorWidth = 14;
|
const authorWidth = 14;
|
||||||
const assigneesWidth = 14;
|
const assigneesWidth = 14;
|
||||||
|
const labelsWidth = 16;
|
||||||
const createdWidth = 12;
|
const createdWidth = 12;
|
||||||
const commentsWidth = 6;
|
const commentsWidth = 6;
|
||||||
const timeWidth = 7;
|
const timeWidth = 7;
|
||||||
// Title takes all remaining space. There are 9 columns + 10 borders + 9 spaces = 19 extra chars
|
// Title takes all remaining space. There are 10 columns + 11 borders + 10 spaces = 21 extra chars
|
||||||
const titleWidth = Math.max(10, cols - idWidth - typeWidth - stateWidth - authorWidth - assigneesWidth - createdWidth - commentsWidth - timeWidth - 19);
|
const titleWidth = Math.max(10, cols - idWidth - typeWidth - stateWidth - authorWidth - assigneesWidth - labelsWidth - createdWidth - commentsWidth - timeWidth - 21);
|
||||||
|
|
||||||
// Render Table Header
|
// Render Table Header
|
||||||
const padHeader = (title: string, w: number) => chalk.bold.white(title.padEnd(w));
|
const padHeader = (title: string, w: number) => chalk.bold.white(title.padEnd(w));
|
||||||
|
|
@ -1140,6 +1154,7 @@ export class TuiEngine {
|
||||||
padHeader('Title', titleWidth) + borderCh + ' ' +
|
padHeader('Title', titleWidth) + borderCh + ' ' +
|
||||||
padHeader('Author', authorWidth) + borderCh + ' ' +
|
padHeader('Author', authorWidth) + borderCh + ' ' +
|
||||||
padHeader('Assignees', assigneesWidth) + borderCh + ' ' +
|
padHeader('Assignees', assigneesWidth) + borderCh + ' ' +
|
||||||
|
padHeader('Labels', labelsWidth) + borderCh + ' ' +
|
||||||
padHeader('Created', createdWidth) + borderCh + ' ' +
|
padHeader('Created', createdWidth) + borderCh + ' ' +
|
||||||
padHeader('Coms', commentsWidth) + borderCh + ' ' +
|
padHeader('Coms', commentsWidth) + borderCh + ' ' +
|
||||||
padHeader('Time', timeWidth) + borderCh
|
padHeader('Time', timeWidth) + borderCh
|
||||||
|
|
@ -1179,6 +1194,26 @@ export class TuiEngine {
|
||||||
const authorStr = truncate(issue.user.login, authorWidth).padEnd(authorWidth);
|
const authorStr = truncate(issue.user.login, authorWidth).padEnd(authorWidth);
|
||||||
const assigneesNames = (issue.assignees || []).map(u => u.login).join(',');
|
const assigneesNames = (issue.assignees || []).map(u => u.login).join(',');
|
||||||
const assigneesStr = truncate(assigneesNames, assigneesWidth).padEnd(assigneesWidth);
|
const assigneesStr = truncate(assigneesNames, assigneesWidth).padEnd(assigneesWidth);
|
||||||
|
let plainLen = 0;
|
||||||
|
let issueLabelsStr = '';
|
||||||
|
for (let idx = 0; idx < issue.labels.length; idx++) {
|
||||||
|
const l = issue.labels[idx];
|
||||||
|
let name = l.name;
|
||||||
|
if (name.length + 2 > labelsWidth) {
|
||||||
|
name = name.substring(0, labelsWidth - 3) + '…';
|
||||||
|
}
|
||||||
|
const lText = ` ${name} `;
|
||||||
|
if (plainLen + lText.length > labelsWidth) break;
|
||||||
|
issueLabelsStr += chalk.bgHex('#' + l.color).black(lText);
|
||||||
|
plainLen += lText.length;
|
||||||
|
if (idx < issue.labels.length - 1 && plainLen + 1 <= labelsWidth) {
|
||||||
|
issueLabelsStr += ' ';
|
||||||
|
plainLen += 1;
|
||||||
|
} else if (idx < issue.labels.length - 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issueLabelsStr += ' '.repeat(Math.max(0, labelsWidth - plainLen));
|
||||||
const createdStr = formatDate(issue.created_at).substring(0, 10).padEnd(createdWidth);
|
const createdStr = formatDate(issue.created_at).substring(0, 10).padEnd(createdWidth);
|
||||||
const commentsStr = String(issue.comments).padEnd(commentsWidth);
|
const commentsStr = String(issue.comments).padEnd(commentsWidth);
|
||||||
const timeStr = formatTime(issue.total_tracked_time).padEnd(timeWidth);
|
const timeStr = formatTime(issue.total_tracked_time).padEnd(timeWidth);
|
||||||
|
|
@ -1190,6 +1225,7 @@ export class TuiEngine {
|
||||||
' ' + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh +
|
' ' + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh +
|
||||||
' ' + authorStr + borderCh +
|
' ' + authorStr + borderCh +
|
||||||
' ' + assigneesStr + borderCh +
|
' ' + assigneesStr + borderCh +
|
||||||
|
' ' + issueLabelsStr + borderCh +
|
||||||
' ' + createdStr + borderCh +
|
' ' + createdStr + borderCh +
|
||||||
' ' + commentsStr + borderCh +
|
' ' + commentsStr + borderCh +
|
||||||
' ' + timeStr + borderCh;
|
' ' + timeStr + borderCh;
|
||||||
|
|
@ -1224,7 +1260,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 fullHelpLine = ' [↑/↓] Navigate [Enter] View [C] Create [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [O] Settings [Esc] Quit';
|
const fullHelpLine = ' [↑/↓] Navigate [Enter] View [C] Create [/] Search [S] Sort [F] State [T] Type [L] Labels [N/P] Page [R] Reload [O] Settings [Esc] Quit';
|
||||||
const repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `);
|
const repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `);
|
||||||
const repoLen = stripAnsi(repoStr).length;
|
const repoLen = stripAnsi(repoStr).length;
|
||||||
|
|
||||||
|
|
@ -2395,8 +2431,295 @@ export class TuiEngine {
|
||||||
|
|
||||||
const printedRows = H + padTopCount + 5;
|
const printedRows = H + padTopCount + 5;
|
||||||
const remainingRows = Math.max(0, rows - printedRows - 1);
|
const remainingRows = Math.max(0, rows - printedRows - 1);
|
||||||
for (let i = 0; i < remainingRows; i++) {
|
}
|
||||||
|
|
||||||
|
// --- Labels Logic ---
|
||||||
|
private async loadLabels() {
|
||||||
|
this.state.labelsLoading = true;
|
||||||
|
this.state.error = null;
|
||||||
|
this.render();
|
||||||
|
try {
|
||||||
|
this.state.labels = await fetchLabels(this.state.config);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.state.error = `Failed to load labels: ${err.message}`;
|
||||||
|
} finally {
|
||||||
|
this.state.labelsLoading = false;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleLabelsListKeypress(str: string, key: any) {
|
||||||
|
if (this.state.labelsLoading) return;
|
||||||
|
|
||||||
|
if ((key && key.name === 'escape') || str === '\u001b' || str === 'q' || str === 'Q') {
|
||||||
|
this.state.screen = 'list';
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key && key.name === 'up') {
|
||||||
|
if (this.state.selectedLabelIndex > 0) {
|
||||||
|
this.state.selectedLabelIndex--;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key && key.name === 'down') {
|
||||||
|
if (this.state.selectedLabelIndex < this.state.labels.length - 1) {
|
||||||
|
this.state.selectedLabelIndex++;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str === 'c' || str === 'C') {
|
||||||
|
this.state.screen = 'create-label';
|
||||||
|
this.state.labelForm = {
|
||||||
|
name: '',
|
||||||
|
color: '000000',
|
||||||
|
description: '',
|
||||||
|
exclusive: false,
|
||||||
|
activeField: 'name'
|
||||||
|
};
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str === 'e' || str === 'E') {
|
||||||
|
if (this.state.labels.length > 0) {
|
||||||
|
const lbl = this.state.labels[this.state.selectedLabelIndex];
|
||||||
|
this.state.screen = 'edit-label';
|
||||||
|
this.state.labelForm = {
|
||||||
|
name: lbl.name,
|
||||||
|
color: lbl.color,
|
||||||
|
description: lbl.description || '',
|
||||||
|
exclusive: lbl.exclusive || false,
|
||||||
|
activeField: 'name'
|
||||||
|
};
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((key && key.name === 'delete') || str === 'd' || str === 'D') {
|
||||||
|
if (this.state.labels.length > 0) {
|
||||||
|
const lbl = this.state.labels[this.state.selectedLabelIndex];
|
||||||
|
this.state.labelsLoading = true;
|
||||||
|
this.render();
|
||||||
|
try {
|
||||||
|
await deleteLabel(this.state.config, lbl.id);
|
||||||
|
await this.loadLabels();
|
||||||
|
} catch (err: any) {
|
||||||
|
this.state.error = `Failed to delete label: ${err.message}`;
|
||||||
|
this.state.labelsLoading = false;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleLabelFormKeypress(str: string, key: any) {
|
||||||
|
if ((key && key.name === 'escape') || str === '\u001b') {
|
||||||
|
this.state.screen = 'labels-list';
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = this.state.labelForm;
|
||||||
|
const fields: Array<'name' | 'color' | 'description' | 'exclusive'> = ['name', 'color', 'description', 'exclusive'];
|
||||||
|
const currentIdx = fields.indexOf(form.activeField);
|
||||||
|
|
||||||
|
if (key && key.name === 'up') {
|
||||||
|
form.activeField = fields[(currentIdx - 1 + fields.length) % fields.length];
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key && key.name === 'down') {
|
||||||
|
form.activeField = fields[(currentIdx + 1) % fields.length];
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key && key.name === 'tab') {
|
||||||
|
form.activeField = fields[(currentIdx + 1) % fields.length];
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str === ' ' && form.activeField === 'exclusive') {
|
||||||
|
form.exclusive = !form.exclusive;
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
|
||||||
|
if (form.activeField === 'exclusive') {
|
||||||
|
form.exclusive = !form.exclusive;
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.name.trim() || !form.color.trim()) {
|
||||||
|
this.state.error = 'Name and Color are required.';
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.labelsLoading = true;
|
||||||
|
this.state.error = null;
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.state.screen === 'create-label') {
|
||||||
|
await createLabel(this.state.config, {
|
||||||
|
name: form.name.trim(),
|
||||||
|
color: form.color.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
exclusive: form.exclusive
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const lbl = this.state.labels[this.state.selectedLabelIndex];
|
||||||
|
await updateLabel(this.state.config, lbl.id, {
|
||||||
|
name: form.name.trim(),
|
||||||
|
color: form.color.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
exclusive: form.exclusive
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.state.screen = 'labels-list';
|
||||||
|
await this.loadLabels();
|
||||||
|
} catch (err: any) {
|
||||||
|
this.state.error = err.message;
|
||||||
|
this.state.labelsLoading = false;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key && key.name === 'backspace') {
|
||||||
|
if (form.activeField === 'name') form.name = form.name.slice(0, -1);
|
||||||
|
if (form.activeField === 'color') form.color = form.color.slice(0, -1);
|
||||||
|
if (form.activeField === 'description') form.description = form.description.slice(0, -1);
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) {
|
||||||
|
if (form.activeField === 'name') form.name += str;
|
||||||
|
if (form.activeField === 'color') form.color += str;
|
||||||
|
if (form.activeField === 'description') form.description += str;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderLabelsList(cols: number, rows: number) {
|
||||||
|
const header = chalk.bgBlue.white.bold(` Repository Labels - ${this.state.config.owner}/${this.state.config.repo} `);
|
||||||
|
const headerPad = Math.max(0, Math.floor((cols - stripAnsi(header).length) / 2));
|
||||||
|
console.log(' '.repeat(headerPad) + header);
|
||||||
|
console.log(chalk.gray('─'.repeat(cols)));
|
||||||
|
|
||||||
|
if (this.state.labelsLoading) {
|
||||||
|
console.log(chalk.yellow(` Loading labels... ${SPINNER_FRAMES[spinnerIndex]}`));
|
||||||
|
} else if (this.state.labels.length === 0) {
|
||||||
|
console.log(chalk.gray(' No labels found.'));
|
||||||
|
} else {
|
||||||
|
const listHeight = rows - 6; // header(2), footer(2), errors etc.
|
||||||
|
let startIdx = 0;
|
||||||
|
if (this.state.selectedLabelIndex >= listHeight) {
|
||||||
|
startIdx = this.state.selectedLabelIndex - listHeight + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < listHeight; i++) {
|
||||||
|
const itemIdx = startIdx + i;
|
||||||
|
if (itemIdx >= this.state.labels.length) break;
|
||||||
|
|
||||||
|
const lbl = this.state.labels[itemIdx];
|
||||||
|
const isSelected = itemIdx === this.state.selectedLabelIndex;
|
||||||
|
|
||||||
|
let prefix = isSelected ? chalk.green(' > ') : ' ';
|
||||||
|
let lblDisplay = chalk.bgHex('#' + lbl.color).black(` ${lbl.name} `);
|
||||||
|
if (lbl.exclusive) lblDisplay += ' (exclusive)';
|
||||||
|
|
||||||
|
let line = prefix + lblDisplay;
|
||||||
|
if (lbl.description) {
|
||||||
|
line += chalk.gray(` - ${truncate(lbl.description, cols - 30)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
console.log(chalk.bgGray(line + ' '.repeat(Math.max(0, cols - stripAnsi(line).length))));
|
||||||
|
} else {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill remaining space
|
||||||
|
const currentLine = this.state.labelsLoading ? 3 : Math.min(this.state.labels.length, rows - 6) + 2;
|
||||||
|
for (let i = currentLine; i < rows - 3; i++) console.log('');
|
||||||
|
|
||||||
|
if (this.state.error) {
|
||||||
|
console.log(chalk.red(` Error: ${this.state.error}`));
|
||||||
|
} else {
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(chalk.gray('─'.repeat(cols)));
|
||||||
|
console.log(chalk.gray(' [c] Create [e] Edit [d] Delete [esc] Back to Issues'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderLabelForm(cols: number, rows: number) {
|
||||||
|
const isEdit = this.state.screen === 'edit-label';
|
||||||
|
const title = isEdit ? 'Edit Label' : 'Create Label';
|
||||||
|
const header = chalk.bgBlue.white.bold(` ${title} `);
|
||||||
|
const headerPad = Math.max(0, Math.floor((cols - stripAnsi(header).length) / 2));
|
||||||
|
console.log(' '.repeat(headerPad) + header);
|
||||||
|
console.log(chalk.gray('─'.repeat(cols)));
|
||||||
|
|
||||||
|
const form = this.state.labelForm;
|
||||||
|
const width = Math.min(60, cols - 4);
|
||||||
|
const leftPadStr = ' '.repeat(Math.max(0, Math.floor((cols - width) / 2)));
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Name
|
||||||
|
const nameLabel = form.activeField === 'name' ? chalk.cyan.bold('> Name: ') : ' Name: ';
|
||||||
|
console.log(leftPadStr + nameLabel + (form.name || chalk.gray('(empty)')));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Color
|
||||||
|
const colorLabel = form.activeField === 'color' ? chalk.cyan.bold('> Color (hex without #): ') : ' Color: ';
|
||||||
|
console.log(leftPadStr + colorLabel + (form.color || chalk.gray('e.g. ff0000')));
|
||||||
|
if (form.color && form.color.length >= 3) {
|
||||||
|
try {
|
||||||
|
console.log(leftPadStr + ' Preview: ' + chalk.bgHex('#' + form.color).black(` ${form.name || 'Label'} `));
|
||||||
|
} catch (e) {
|
||||||
|
console.log(leftPadStr + ' Preview: ' + chalk.red('Invalid color'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Description
|
||||||
|
const descLabel = form.activeField === 'description' ? chalk.cyan.bold('> Description: ') : ' Description: ';
|
||||||
|
console.log(leftPadStr + descLabel + (form.description || chalk.gray('(empty)')));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Exclusive
|
||||||
|
const exclLabel = form.activeField === 'exclusive' ? chalk.cyan.bold('> Exclusive: ') : ' Exclusive: ';
|
||||||
|
console.log(leftPadStr + exclLabel + (form.exclusive ? chalk.green('[x]') : '[ ]'));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
for (let i = 13; i < rows - 3; i++) console.log('');
|
||||||
|
|
||||||
|
if (this.state.error) {
|
||||||
|
console.log(leftPadStr + chalk.red(` Error: ${this.state.error}`));
|
||||||
|
} else if (this.state.labelsLoading) {
|
||||||
|
console.log(leftPadStr + chalk.yellow(` Saving... ${SPINNER_FRAMES[spinnerIndex]}`));
|
||||||
|
} else {
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.gray('─'.repeat(cols)));
|
||||||
|
console.log(chalk.gray(' [↑/↓/Tab] Navigate [Enter] Save [Esc] Cancel'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
src/types.ts
20
src/types.ts
|
|
@ -16,6 +16,8 @@ export interface Label {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
description?: string;
|
||||||
|
exclusive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Issue {
|
export interface Issue {
|
||||||
|
|
@ -70,7 +72,15 @@ export interface AddTimeForm {
|
||||||
timeInput: string;
|
timeInput: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScreenType = 'launch' | 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen' | 'set-assignees';
|
export interface LabelForm {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
description: string;
|
||||||
|
exclusive: boolean;
|
||||||
|
activeField: 'name' | 'color' | 'description' | 'exclusive';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScreenType = 'launch' | 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen' | 'set-assignees' | 'labels-list' | 'create-label' | 'edit-label';
|
||||||
|
|
||||||
|
|
||||||
export interface RepoItem {
|
export interface RepoItem {
|
||||||
|
|
@ -126,5 +136,13 @@ export interface AppState {
|
||||||
|
|
||||||
// Add Time Form State
|
// Add Time Form State
|
||||||
addTimeForm: AddTimeForm;
|
addTimeForm: AddTimeForm;
|
||||||
|
|
||||||
|
// Labels List State
|
||||||
|
labels: Label[];
|
||||||
|
selectedLabelIndex: number;
|
||||||
|
labelsLoading: boolean;
|
||||||
|
|
||||||
|
// Label Form State
|
||||||
|
labelForm: LabelForm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue