redmine-context 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -5
- package/dist/bundle/journal-detail.d.ts +89 -0
- package/dist/bundle/journal-detail.js +240 -0
- package/dist/bundle/markdown.d.ts +7 -0
- package/dist/bundle/markdown.js +21 -12
- package/dist/client/enumerations.d.ts +39 -0
- package/dist/client/enumerations.js +76 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/fetch-issue-bundle.js +17 -2
- package/dist/fetch-last-issues.d.ts +96 -0
- package/dist/fetch-last-issues.js +120 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +22 -2
- package/dist/normalize/issue.js +32 -3
- package/dist/surfaces/cli/commands.d.ts +17 -0
- package/dist/surfaces/cli/commands.js +105 -12
- package/dist/surfaces/cli/main.js +22 -3
- package/dist/surfaces/mcp/server.d.ts +22 -45
- package/dist/surfaces/mcp/server.js +67 -56
- package/dist/surfaces/mcp/tools.d.ts +109 -0
- package/dist/surfaces/mcp/tools.js +93 -0
- package/dist/surfaces/tui/app.d.ts +14 -0
- package/dist/surfaces/tui/app.js +32 -1
- package/dist/surfaces/tui/banner.d.ts +43 -0
- package/dist/surfaces/tui/banner.js +93 -0
- package/dist/surfaces/tui/components/gauge.d.ts +27 -0
- package/dist/surfaces/tui/components/gauge.js +53 -0
- package/dist/surfaces/tui/components/gradient-banner.d.ts +13 -0
- package/dist/surfaces/tui/components/gradient-banner.js +41 -0
- package/dist/surfaces/tui/glyphs.d.ts +4 -0
- package/dist/surfaces/tui/glyphs.js +4 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +7 -1
- package/dist/surfaces/tui/hooks/use-issue-detail.js +15 -2
- package/dist/surfaces/tui/screens/issue-detail.js +28 -7
- package/dist/surfaces/tui/screens/jobs.js +3 -2
- package/dist/surfaces/tui/screens/welcome.js +9 -1
- package/dist/surfaces/tui/wrap.d.ts +38 -0
- package/dist/surfaces/tui/wrap.js +82 -0
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arte ASCII do banner da tela de abertura (#190 — pacote estético).
|
|
3
|
+
*
|
|
4
|
+
* As três variantes são CONSTANTES, não geradas em runtime: o texto é sempre
|
|
5
|
+
* `redmine-context`, então trazer uma dependência de fontes (figlet/cfonts) para
|
|
6
|
+
* recalcular a mesma string a cada boot não se paga — custaria peso no pacote e
|
|
7
|
+
* tempo de start. Para regenerar (fonte `ANSI Shadow` / `Small` do figlet):
|
|
8
|
+
*
|
|
9
|
+
* npx figlet -f "ANSI Shadow" redmine-context
|
|
10
|
+
* npx figlet -f "ANSI Shadow" redmine ; npx figlet -f "ANSI Shadow" context
|
|
11
|
+
* npx figlet -f Small redmine-context
|
|
12
|
+
*
|
|
13
|
+
* A seleção é PURA ({@link selectBanner}) e depende de dois sinais que a TUI já
|
|
14
|
+
* possui: a largura do terminal (`../hooks/use-terminal-width.js`) e o suporte a
|
|
15
|
+
* Unicode (`../glyphs.js`) — o mesmo sinal que degrada os frames braille do
|
|
16
|
+
* spinner no terminal legado do Windows (M5-09, #84). Sem essa degradação, os
|
|
17
|
+
* blocos `█`/`╗` do ANSI Shadow virariam mojibake.
|
|
18
|
+
*/
|
|
19
|
+
const WIDE_LINES = [
|
|
20
|
+
'██████╗ ███████╗██████╗ ███╗ ███╗██╗███╗ ██╗███████╗ ██████╗ ██████╗ ███╗ ██╗████████╗███████╗██╗ ██╗████████╗',
|
|
21
|
+
'██╔══██╗██╔════╝██╔══██╗████╗ ████║██║████╗ ██║██╔════╝ ██╔════╝██╔═══██╗████╗ ██║╚══██╔══╝██╔════╝╚██╗██╔╝╚══██╔══╝',
|
|
22
|
+
'██████╔╝█████╗ ██║ ██║██╔████╔██║██║██╔██╗ ██║█████╗█████╗██║ ██║ ██║██╔██╗ ██║ ██║ █████╗ ╚███╔╝ ██║ ',
|
|
23
|
+
'██╔══██╗██╔══╝ ██║ ██║██║╚██╔╝██║██║██║╚██╗██║██╔══╝╚════╝██║ ██║ ██║██║╚██╗██║ ██║ ██╔══╝ ██╔██╗ ██║ ',
|
|
24
|
+
'██║ ██║███████╗██████╔╝██║ ╚═╝ ██║██║██║ ╚████║███████╗ ╚██████╗╚██████╔╝██║ ╚████║ ██║ ███████╗██╔╝ ██╗ ██║ ',
|
|
25
|
+
'╚═╝ ╚═╝╚══════╝╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ',
|
|
26
|
+
];
|
|
27
|
+
const STACKED_LINES = [
|
|
28
|
+
'██████╗ ███████╗██████╗ ███╗ ███╗██╗███╗ ██╗███████╗',
|
|
29
|
+
'██╔══██╗██╔════╝██╔══██╗████╗ ████║██║████╗ ██║██╔════╝',
|
|
30
|
+
'██████╔╝█████╗ ██║ ██║██╔████╔██║██║██╔██╗ ██║█████╗ ',
|
|
31
|
+
'██╔══██╗██╔══╝ ██║ ██║██║╚██╔╝██║██║██║╚██╗██║██╔══╝ ',
|
|
32
|
+
'██║ ██║███████╗██████╔╝██║ ╚═╝ ██║██║██║ ╚████║███████╗',
|
|
33
|
+
'╚═╝ ╚═╝╚══════╝╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝',
|
|
34
|
+
' ██████╗ ██████╗ ███╗ ██╗████████╗███████╗██╗ ██╗████████╗',
|
|
35
|
+
'██╔════╝██╔═══██╗████╗ ██║╚══██╔══╝██╔════╝╚██╗██╔╝╚══██╔══╝',
|
|
36
|
+
'██║ ██║ ██║██╔██╗ ██║ ██║ █████╗ ╚███╔╝ ██║ ',
|
|
37
|
+
'██║ ██║ ██║██║╚██╗██║ ██║ ██╔══╝ ██╔██╗ ██║ ',
|
|
38
|
+
'╚██████╗╚██████╔╝██║ ╚████║ ██║ ███████╗██╔╝ ██╗ ██║ ',
|
|
39
|
+
' ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ',
|
|
40
|
+
];
|
|
41
|
+
const ASCII_LINES = [
|
|
42
|
+
' _ _ _ _ ',
|
|
43
|
+
' _ _ ___ __| |_ __ (_)_ _ ___ ___ __ ___ _ _| |_ _____ _| |_ ',
|
|
44
|
+
' | \'_/ -_) _` | \' \\| | \' \\/ -_)___/ _/ _ \\ \' \\ _/ -_) \\ / _|',
|
|
45
|
+
' |_| \\___\\__,_|_|_|_|_|_||_\\___| \\__\\___/_||_\\__\\___/_\\_\\\\__|',
|
|
46
|
+
];
|
|
47
|
+
/** Largura mínima (colunas) exigida por cada variante. */
|
|
48
|
+
const MIN_WIDTH = {
|
|
49
|
+
wide: 121,
|
|
50
|
+
stacked: 61,
|
|
51
|
+
ascii: 63,
|
|
52
|
+
};
|
|
53
|
+
/** Linhas de cada variante, na ordem de renderização. */
|
|
54
|
+
const LINES = {
|
|
55
|
+
wide: WIDE_LINES,
|
|
56
|
+
stacked: STACKED_LINES,
|
|
57
|
+
ascii: ASCII_LINES,
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Escolhe a variante do banner para o terminal atual.
|
|
61
|
+
*
|
|
62
|
+
* Ordem de preferência, sempre respeitando a largura disponível:
|
|
63
|
+
* `wide` (uma linha, Unicode) → `stacked` (duas linhas, Unicode) → `ascii`
|
|
64
|
+
* (ASCII puro) → `plain` (sem arte; a tela cai no nome em texto).
|
|
65
|
+
*
|
|
66
|
+
* @param width - Colunas disponíveis (ver `useTerminalWidth`).
|
|
67
|
+
* @param unicode - `true` quando o terminal renderiza os blocos do ANSI Shadow.
|
|
68
|
+
* @returns A variante a renderizar.
|
|
69
|
+
* @example
|
|
70
|
+
* selectBanner(140, true) // 'wide'
|
|
71
|
+
* selectBanner(80, true) // 'stacked' (não cabe a wide)
|
|
72
|
+
* selectBanner(80, false) // 'ascii' (sem Unicode)
|
|
73
|
+
*/
|
|
74
|
+
export function selectBanner(width, unicode) {
|
|
75
|
+
if (unicode) {
|
|
76
|
+
if (width >= MIN_WIDTH.wide)
|
|
77
|
+
return 'wide';
|
|
78
|
+
if (width >= MIN_WIDTH.stacked)
|
|
79
|
+
return 'stacked';
|
|
80
|
+
}
|
|
81
|
+
if (width >= MIN_WIDTH.ascii)
|
|
82
|
+
return 'ascii';
|
|
83
|
+
return 'plain';
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Linhas da variante escolhida.
|
|
87
|
+
*
|
|
88
|
+
* @param variant - Variante de {@link selectBanner}.
|
|
89
|
+
* @returns As linhas da arte; vazio para `plain`.
|
|
90
|
+
*/
|
|
91
|
+
export function bannerLines(variant) {
|
|
92
|
+
return variant === 'plain' ? [] : LINES[variant];
|
|
93
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converte a fração de progresso em número de células preenchidas.
|
|
3
|
+
*
|
|
4
|
+
* Exportada para teste: as bordas (0, 1 e valores fora da faixa) são o que mais
|
|
5
|
+
* erra em barra de progresso.
|
|
6
|
+
*
|
|
7
|
+
* @param progress - Fração entre 0 e 1; valores fora da faixa são fixados nela.
|
|
8
|
+
* @param width - Largura total da barra em células.
|
|
9
|
+
* @returns Quantidade de células preenchidas, sempre em `[0, width]`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function filledCells(progress: number, width: number): number;
|
|
12
|
+
/**
|
|
13
|
+
* Renderiza uma barra de progresso com percentual.
|
|
14
|
+
*
|
|
15
|
+
* @param props.progress - Fração entre 0 e 1.
|
|
16
|
+
* @param props.color - Cor do preenchimento (token do tema).
|
|
17
|
+
* @param props.trackColor - Cor do trilho vazio (normalmente `theme.muted`).
|
|
18
|
+
* @param props.width - Largura em células. Default: {@link DEFAULT_WIDTH}.
|
|
19
|
+
* @example
|
|
20
|
+
* <Gauge progress={0.4} color={theme.primary} trackColor={theme.muted} />
|
|
21
|
+
*/
|
|
22
|
+
export declare function Gauge({ progress, color, trackColor, width, }: {
|
|
23
|
+
progress: number;
|
|
24
|
+
color: string;
|
|
25
|
+
trackColor: string;
|
|
26
|
+
width?: number;
|
|
27
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Barra de progresso (#190 — pacote estético).
|
|
4
|
+
*
|
|
5
|
+
* O `Job` do registro (`../job-registry.tsx`) já carregava `progress`, mas a
|
|
6
|
+
* tela de jobs só mostrava o status textual — a extração de mídia (OCR, ffmpeg,
|
|
7
|
+
* whisper) roda em background e o usuário não tinha noção de quanto falta. Este
|
|
8
|
+
* componente dá essa leitura.
|
|
9
|
+
*
|
|
10
|
+
* Glyphs vêm de `../glyphs.js` (`gaugeFull`/`gaugeEmpty`), então a barra degrada
|
|
11
|
+
* para `#`/`-` no terminal legado do Windows pelo MESMO sinal que já degrada os
|
|
12
|
+
* frames braille do spinner — sem caractere Unicode solto na tela.
|
|
13
|
+
*
|
|
14
|
+
* Cores vêm do tema (varredura `no-hardcoded-colors`): o preenchimento usa a cor
|
|
15
|
+
* recebida e o trilho usa `muted`, o que preserva contraste tanto nas paletas
|
|
16
|
+
* escuras quanto nas claras.
|
|
17
|
+
*/
|
|
18
|
+
import { Text } from 'ink';
|
|
19
|
+
import { glyphs } from '../glyphs.js';
|
|
20
|
+
/** Largura default da barra, em células. */
|
|
21
|
+
const DEFAULT_WIDTH = 20;
|
|
22
|
+
/**
|
|
23
|
+
* Converte a fração de progresso em número de células preenchidas.
|
|
24
|
+
*
|
|
25
|
+
* Exportada para teste: as bordas (0, 1 e valores fora da faixa) são o que mais
|
|
26
|
+
* erra em barra de progresso.
|
|
27
|
+
*
|
|
28
|
+
* @param progress - Fração entre 0 e 1; valores fora da faixa são fixados nela.
|
|
29
|
+
* @param width - Largura total da barra em células.
|
|
30
|
+
* @returns Quantidade de células preenchidas, sempre em `[0, width]`.
|
|
31
|
+
*/
|
|
32
|
+
export function filledCells(progress, width) {
|
|
33
|
+
if (!Number.isFinite(progress) || progress <= 0)
|
|
34
|
+
return 0;
|
|
35
|
+
if (progress >= 1)
|
|
36
|
+
return width;
|
|
37
|
+
return Math.min(width, Math.max(0, Math.round(progress * width)));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Renderiza uma barra de progresso com percentual.
|
|
41
|
+
*
|
|
42
|
+
* @param props.progress - Fração entre 0 e 1.
|
|
43
|
+
* @param props.color - Cor do preenchimento (token do tema).
|
|
44
|
+
* @param props.trackColor - Cor do trilho vazio (normalmente `theme.muted`).
|
|
45
|
+
* @param props.width - Largura em células. Default: {@link DEFAULT_WIDTH}.
|
|
46
|
+
* @example
|
|
47
|
+
* <Gauge progress={0.4} color={theme.primary} trackColor={theme.muted} />
|
|
48
|
+
*/
|
|
49
|
+
export function Gauge({ progress, color, trackColor, width = DEFAULT_WIDTH, }) {
|
|
50
|
+
const filled = filledCells(progress, width);
|
|
51
|
+
const percent = Math.round(Math.min(1, Math.max(0, progress)) * 100);
|
|
52
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: color, children: glyphs.gaugeFull.repeat(filled) }), _jsx(Text, { color: trackColor, children: glyphs.gaugeEmpty.repeat(width - filled) }), _jsxs(Text, { color: trackColor, children: [" ", String(percent).padStart(3), "%"] })] }));
|
|
53
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderiza linhas de arte ASCII com gradiente por coluna.
|
|
3
|
+
*
|
|
4
|
+
* @param props.lines - Linhas da arte (mesma fonte, alturas iguais).
|
|
5
|
+
* @param props.colors - Ramp de cores (hex) da paleta ativa; vazia ⇒ sem cor.
|
|
6
|
+
* @returns O banner, ou `null` quando não há linhas (variante `plain`).
|
|
7
|
+
* @example
|
|
8
|
+
* <GradientBanner lines={bannerLines('wide')} colors={theme.gradient ?? []} />
|
|
9
|
+
*/
|
|
10
|
+
export declare function GradientBanner({ lines, colors, }: {
|
|
11
|
+
lines: readonly string[];
|
|
12
|
+
colors: readonly string[];
|
|
13
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Banner multi-linha com gradiente HORIZONTAL (#190 — pacote estético).
|
|
4
|
+
*
|
|
5
|
+
* Diferença para {@link GradientText}: aquele interpola por CARACTERE ao longo de
|
|
6
|
+
* uma string única — passar uma arte multi-linha a ele faria a ramp escorrer
|
|
7
|
+
* pelas quebras de linha e sair na diagonal, com cada linha começando numa cor
|
|
8
|
+
* diferente. Aqui a ramp é calculada UMA vez pela largura da arte e aplicada por
|
|
9
|
+
* COLUNA: a coluna `x` tem a mesma cor em todas as linhas, que é o que faz o
|
|
10
|
+
* logo ler como um objeto só.
|
|
11
|
+
*
|
|
12
|
+
* Reutiliza `rampColors` de `./gradient-text.js` — a interpolação é a mesma, só
|
|
13
|
+
* muda o eixo. Nenhuma cor literal aqui: a ramp vem do tema (varredura
|
|
14
|
+
* `no-hardcoded-colors`).
|
|
15
|
+
*
|
|
16
|
+
* Contraste: as paradas de cor são as da paleta ativa (claras e escuras já vêm
|
|
17
|
+
* calibradas em `../palettes.ts`), então o banner herda o contraste do tema em
|
|
18
|
+
* vez de impor um seu. Sem `bold` — o atributo derruba o fg truecolor em alguns
|
|
19
|
+
* terminais (ver a nota em `./gradient-text.tsx`).
|
|
20
|
+
*/
|
|
21
|
+
import { Box, Text } from 'ink';
|
|
22
|
+
import { rampColors } from './gradient-text.js';
|
|
23
|
+
/**
|
|
24
|
+
* Renderiza linhas de arte ASCII com gradiente por coluna.
|
|
25
|
+
*
|
|
26
|
+
* @param props.lines - Linhas da arte (mesma fonte, alturas iguais).
|
|
27
|
+
* @param props.colors - Ramp de cores (hex) da paleta ativa; vazia ⇒ sem cor.
|
|
28
|
+
* @returns O banner, ou `null` quando não há linhas (variante `plain`).
|
|
29
|
+
* @example
|
|
30
|
+
* <GradientBanner lines={bannerLines('wide')} colors={theme.gradient ?? []} />
|
|
31
|
+
*/
|
|
32
|
+
export function GradientBanner({ lines, colors, }) {
|
|
33
|
+
if (lines.length === 0)
|
|
34
|
+
return null;
|
|
35
|
+
const width = Math.max(...lines.map((line) => [...line].length));
|
|
36
|
+
const ramp = rampColors(colors, width);
|
|
37
|
+
return (_jsx(Box, { flexDirection: "column", children: lines.map((line, row) => (_jsx(Text, { children: [...line].map((ch, col) => (
|
|
38
|
+
// Espaço não recebe cor: evita pintar o "fundo" da arte e mantém o
|
|
39
|
+
// banner legível também em paleta clara.
|
|
40
|
+
_jsx(Text, { ...(ch === ' ' || ramp.length === 0 ? {} : { color: ramp[col] }), children: ch }, col))) }, row))) }));
|
|
41
|
+
}
|
|
@@ -31,6 +31,10 @@ export interface Glyphs {
|
|
|
31
31
|
readonly maskBullet: string;
|
|
32
32
|
/** Frames do spinner (`components/spinner.tsx`). */
|
|
33
33
|
readonly spinnerFrames: readonly string[];
|
|
34
|
+
/** Célula PREENCHIDA da barra de progresso (`components/gauge.tsx`). */
|
|
35
|
+
readonly gaugeFull: string;
|
|
36
|
+
/** Célula VAZIA da barra de progresso (`components/gauge.tsx`). */
|
|
37
|
+
readonly gaugeEmpty: string;
|
|
34
38
|
}
|
|
35
39
|
/** Glyphs Unicode — terminais modernos (Windows Terminal, iTerm, etc.). */
|
|
36
40
|
export declare const UNICODE_GLYPHS: Glyphs;
|
|
@@ -60,6 +60,8 @@ export const UNICODE_GLYPHS = {
|
|
|
60
60
|
arrowDown: '↓',
|
|
61
61
|
maskBullet: '•',
|
|
62
62
|
spinnerFrames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
|
63
|
+
gaugeFull: '█',
|
|
64
|
+
gaugeEmpty: '░',
|
|
63
65
|
};
|
|
64
66
|
/** Fallback ASCII puro — terminal legado do Windows (sem mojibake). */
|
|
65
67
|
export const ASCII_GLYPHS = {
|
|
@@ -70,6 +72,8 @@ export const ASCII_GLYPHS = {
|
|
|
70
72
|
arrowDown: 'v',
|
|
71
73
|
maskBullet: '*',
|
|
72
74
|
spinnerFrames: ['|', '/', '-', '\\'],
|
|
75
|
+
gaugeFull: '#',
|
|
76
|
+
gaugeEmpty: '-',
|
|
73
77
|
};
|
|
74
78
|
/**
|
|
75
79
|
* Escolhe o conjunto de glyphs conforme o suporte a Unicode.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHttpClient, getIssue, normalizeIssue, resolveApiKey, type Issue } from '../../../index.js';
|
|
1
|
+
import { createHttpClient, fetchEnumerations, getIssue, normalizeIssue, resolveApiKey, type DetailLookups, type Issue } from '../../../index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Estado da busca de detalhe de issue, consumido por
|
|
4
4
|
* `../screens/issue-detail.tsx`. `no-selection` cobre o caso defensivo de a
|
|
@@ -14,6 +14,7 @@ export type IssueDetailState = {
|
|
|
14
14
|
} | {
|
|
15
15
|
status: 'loaded';
|
|
16
16
|
issue: Issue;
|
|
17
|
+
lookups: DetailLookups;
|
|
17
18
|
} | {
|
|
18
19
|
status: 'error-network';
|
|
19
20
|
message: string;
|
|
@@ -39,6 +40,11 @@ export interface UseIssueDetailOptions {
|
|
|
39
40
|
getIssue?: typeof getIssue;
|
|
40
41
|
/** Normaliza o payload bruto no modelo `Issue`; default `normalizeIssue` do core. */
|
|
41
42
|
normalizeIssue?: typeof normalizeIssue;
|
|
43
|
+
/**
|
|
44
|
+
* Busca as enumerações da instância (`id → nome`); default
|
|
45
|
+
* `fetchEnumerations` do core. Injetável para os testes não tocarem a rede.
|
|
46
|
+
*/
|
|
47
|
+
fetchEnumerations?: typeof fetchEnumerations;
|
|
42
48
|
}
|
|
43
49
|
/** Valor retornado pelo hook. */
|
|
44
50
|
export interface UseIssueDetailResult {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* `auth-aborted` (neutro, não um erro), com `retry` disponível.
|
|
28
28
|
*/
|
|
29
29
|
import { useCallback, useEffect, useState } from 'react';
|
|
30
|
-
import { createHttpClient, getIssue, normalizeIssue, resolveApiKey, RedmineForbiddenError, RedmineNotFoundError, } from '../../../index.js';
|
|
30
|
+
import { createHttpClient, collectUsers, fetchEnumerations, getIssue, normalizeIssue, resolveApiKey, RedmineForbiddenError, RedmineNotFoundError, } from '../../../index.js';
|
|
31
31
|
import { useEnvFallbackAllowed } from '../instance.js';
|
|
32
32
|
import { ReAuthAbortedError, useAuthGuard } from './use-auth-guard.js';
|
|
33
33
|
/** Lê `REDMINE_URL` do ambiente, tratando string vazia como ausente (mesma convenção do doctor/home). */
|
|
@@ -52,6 +52,7 @@ export function useIssueDetail(issueId, options = {}) {
|
|
|
52
52
|
const buildClient = options.createHttpClient ?? createHttpClient;
|
|
53
53
|
const fetchIssue = options.getIssue ?? getIssue;
|
|
54
54
|
const normalize = options.normalizeIssue ?? normalizeIssue;
|
|
55
|
+
const loadEnumerations = options.fetchEnumerations ?? fetchEnumerations;
|
|
55
56
|
// Mandatório (M2-13, #36): ver o JSDoc do módulo.
|
|
56
57
|
const { guard } = useAuthGuard();
|
|
57
58
|
// SEGURANÇA (#187): origem config (URL persistida) → sem env-key instance-agnóstica.
|
|
@@ -93,7 +94,19 @@ export function useIssueDetail(issueId, options = {}) {
|
|
|
93
94
|
const payload = await guard(() => fetchIssue(http, issueId));
|
|
94
95
|
if (cancelled)
|
|
95
96
|
return;
|
|
96
|
-
|
|
97
|
+
const issue = normalize(payload);
|
|
98
|
+
// Dicionários `id → nome` para o histórico: sem eles a tela mostra
|
|
99
|
+
// `Status: #12 → Atribuída (#7)` — metade ilegível. `fetchEnumerations`
|
|
100
|
+
// é memoizado por instância (uma vez por sessão da TUI) e degrada em
|
|
101
|
+
// silêncio; os usuários saem do próprio payload, sem rede.
|
|
102
|
+
const enums = issue.journals.length > 0 ? await loadEnumerations(http, instanceUrl) : undefined;
|
|
103
|
+
if (cancelled)
|
|
104
|
+
return;
|
|
105
|
+
setState({
|
|
106
|
+
status: 'loaded',
|
|
107
|
+
issue,
|
|
108
|
+
lookups: { ...(enums ?? {}), user: collectUsers(issue) },
|
|
109
|
+
});
|
|
97
110
|
}
|
|
98
111
|
catch (cause) {
|
|
99
112
|
if (cancelled)
|
|
@@ -35,17 +35,19 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
35
35
|
*/
|
|
36
36
|
import { Box, Text, useInput } from 'ink';
|
|
37
37
|
import { useEffect, useRef } from 'react';
|
|
38
|
+
import { journalDetailLabel, journalDetailValue } from '../../../index.js';
|
|
38
39
|
import { attachmentStatusColor, attachmentStatusLabel, deriveAttachmentExtractionStatus, } from '../attachment-status.js';
|
|
39
40
|
import { Spinner } from '../components/spinner.js';
|
|
40
41
|
import { ScrollView } from '../components/scroll-view.js';
|
|
41
42
|
import { glyphs } from '../glyphs.js';
|
|
42
43
|
import { humanizeFileSize } from '../format-file-size.js';
|
|
43
44
|
import { useIssueDetail } from '../hooks/use-issue-detail.js';
|
|
44
|
-
import { useTerminalHeight } from '../hooks/use-terminal-width.js';
|
|
45
|
+
import { useTerminalHeight, useTerminalWidth } from '../hooks/use-terminal-width.js';
|
|
45
46
|
import { useNavigation } from '../navigation.js';
|
|
46
47
|
import { statusColor } from '../status-color.js';
|
|
47
48
|
import { symbols } from '../symbols.js';
|
|
48
49
|
import { useTheme } from '../theme.js';
|
|
50
|
+
import { wrapText } from '../wrap.js';
|
|
49
51
|
import { useHomeSelection } from './home-selection.js';
|
|
50
52
|
import { useLoadedIssue } from './loaded-issue-context.js';
|
|
51
53
|
/**
|
|
@@ -58,6 +60,10 @@ import { useLoadedIssue } from './loaded-issue-context.js';
|
|
|
58
60
|
const CONTENT_OVERHEAD_ROWS = 14;
|
|
59
61
|
/** Piso da viewport (terminais muito baixos). */
|
|
60
62
|
const CONTENT_MIN_HEIGHT = 6;
|
|
63
|
+
/** Colunas consumidas fora do texto: moldura da aplicação (2) + padding da tela (2). */
|
|
64
|
+
const CONTENT_WIDTH_OVERHEAD = 4;
|
|
65
|
+
/** Piso da largura de texto (terminais muito estreitos). */
|
|
66
|
+
const CONTENT_MIN_WIDTH = 20;
|
|
61
67
|
/** Placeholder discreto para campos ausentes (assignee, autor/data de journal, old/new value). ASCII no Windows legado (#84). */
|
|
62
68
|
const EMPTY_PLACEHOLDER = glyphs.emptyPlaceholder;
|
|
63
69
|
/** Metadados fixos no topo: id/subject, status/prioridade, autor/responsável, datas. */
|
|
@@ -85,14 +91,17 @@ function buildAttachmentRows(issue, theme) {
|
|
|
85
91
|
return rows;
|
|
86
92
|
}
|
|
87
93
|
/** Constrói as linhas da viewport rolável: bloco de descrição + histórico cronológico de journals. */
|
|
88
|
-
function buildContentRows(issue, theme) {
|
|
94
|
+
function buildContentRows(issue, theme, width, lookups) {
|
|
89
95
|
const rows = [];
|
|
90
96
|
rows.push(_jsx(Text, { color: theme.primary, children: "Descri\u00E7\u00E3o" }, "desc-header"));
|
|
91
97
|
if (issue.description === undefined || issue.description === '') {
|
|
92
98
|
rows.push(_jsx(Text, { color: theme.muted, children: "(sem descri\u00E7\u00E3o)" }, "desc-empty"));
|
|
93
99
|
}
|
|
94
100
|
else {
|
|
95
|
-
|
|
101
|
+
// Quebra na largura ANTES de virar item: o ScrollView conta itens como
|
|
102
|
+
// linhas de tela, e um parágrafo longo (o caso comum de um chamado) faria o
|
|
103
|
+
// Ink quebrá-lo sozinho em várias, estourando o viewport.
|
|
104
|
+
wrapText(issue.description, width).forEach((line, index) => {
|
|
96
105
|
rows.push(_jsx(Text, { children: line.length > 0 ? line : ' ' }, `desc-${index}`));
|
|
97
106
|
});
|
|
98
107
|
}
|
|
@@ -106,13 +115,21 @@ function buildContentRows(issue, theme) {
|
|
|
106
115
|
issue.journals.forEach((journal) => {
|
|
107
116
|
rows.push(_jsxs(Text, { color: theme.muted, children: [journal.user?.name ?? EMPTY_PLACEHOLDER, " ", glyphs.middleDot, " ", journal.created_on] }, `journal-${journal.id}-head`));
|
|
108
117
|
if (journal.notes !== undefined && journal.notes !== '') {
|
|
109
|
-
journal.notes
|
|
118
|
+
wrapText(journal.notes, width).forEach((line, index) => {
|
|
110
119
|
rows.push(_jsx(Text, { children: line }, `journal-${journal.id}-note-${index}`));
|
|
111
120
|
});
|
|
112
121
|
}
|
|
113
|
-
// Details
|
|
122
|
+
// Details resumidos: `campo: antigo → novo`, sempre em muted. Rótulos e
|
|
123
|
+
// ids vêm da MESMA semântica do bundle (`journalDetailLabel`/`Value` do
|
|
124
|
+
// core) — sem ela a tela mostrava o cru do Redmine (`status_id: 12 → 7`,
|
|
125
|
+
// custom field pelo id). A marca `trusted` das partes não se aplica aqui:
|
|
126
|
+
// ela existe para a fence anti prompt-injection do bundle, e esta tela é
|
|
127
|
+
// interface, não prompt.
|
|
114
128
|
journal.details.forEach((detail, index) => {
|
|
115
|
-
|
|
129
|
+
const label = journalDetailLabel(detail, issue).text;
|
|
130
|
+
const from = journalDetailValue(detail, detail.old_value, issue, lookups)?.text ?? EMPTY_PLACEHOLDER;
|
|
131
|
+
const to = journalDetailValue(detail, detail.new_value, issue, lookups)?.text ?? EMPTY_PLACEHOLDER;
|
|
132
|
+
rows.push(_jsxs(Text, { color: theme.muted, children: [' ', label, ": ", from, " ", symbols.arrowRight, " ", to] }, `journal-${journal.id}-detail-${index}`));
|
|
116
133
|
});
|
|
117
134
|
rows.push(_jsx(Text, { children: " " }, `journal-${journal.id}-spacer`));
|
|
118
135
|
});
|
|
@@ -141,6 +158,10 @@ export function IssueDetailScreen() {
|
|
|
141
158
|
// Viewport de descrição CRESCE com o terminal (#190) — só rola quando o conteúdo
|
|
142
159
|
// passa da tela de verdade, em vez de uma janelinha fixa com espaço vazio embaixo.
|
|
143
160
|
const contentHeight = Math.max(CONTENT_MIN_HEIGHT, useTerminalHeight() - CONTENT_OVERHEAD_ROWS);
|
|
161
|
+
// Largura útil do texto: o terminal menos a moldura da aplicação (2 colunas)
|
|
162
|
+
// e o padding desta tela (2). Sem descontar, o parágrafo encosta na borda e o
|
|
163
|
+
// Ink o requebra — o mesmo estouro que o wrap existe para evitar.
|
|
164
|
+
const contentWidth = Math.max(CONTENT_MIN_WIDTH, useTerminalWidth() - CONTENT_WIDTH_OVERHEAD);
|
|
144
165
|
// Espelha a issue carregada no contexto leve consumido por `./export.js`
|
|
145
166
|
// (#33) — ver o JSDoc do módulo e de `./loaded-issue-context.js` para a
|
|
146
167
|
// escolha de reuso em memória em vez de refetch.
|
|
@@ -175,7 +196,7 @@ export function IssueDetailScreen() {
|
|
|
175
196
|
retry();
|
|
176
197
|
}
|
|
177
198
|
});
|
|
178
|
-
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.primary, children: "Detalhe da issue" }), state.status === 'no-selection' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "Nenhuma issue selecionada." }) })) : null, state.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Carregando issue..."] }) })) : null, state.status === 'error-network' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " Falha ao carregar a issue: ", state.message] }) })) : null, state.status === 'error-forbidden' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }) })) : null, state.status === 'error-not-found' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }) })) : null, state.status === 'auth-aborted' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: state.message }) })) : null, state.status === 'loaded' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(IssueMeta, { issue: state.issue, theme: theme }), _jsx(Box, { marginTop: 1, children: _jsx(ScrollView, { lines: buildContentRows(state.issue, theme), height: contentHeight }) })] })) : null, _jsx(Box, { flexGrow: 1 }), _jsx(Footer, { showRetry: state.status === 'error-network' ||
|
|
199
|
+
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.primary, children: "Detalhe da issue" }), state.status === 'no-selection' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "Nenhuma issue selecionada." }) })) : null, state.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Carregando issue..."] }) })) : null, state.status === 'error-network' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " Falha ao carregar a issue: ", state.message] }) })) : null, state.status === 'error-forbidden' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }) })) : null, state.status === 'error-not-found' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }) })) : null, state.status === 'auth-aborted' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: state.message }) })) : null, state.status === 'loaded' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(IssueMeta, { issue: state.issue, theme: theme }), _jsx(Box, { marginTop: 1, children: _jsx(ScrollView, { lines: buildContentRows(state.issue, theme, contentWidth, state.lookups), height: contentHeight }) })] })) : null, _jsx(Box, { flexGrow: 1 }), _jsx(Footer, { showRetry: state.status === 'error-network' ||
|
|
179
200
|
state.status === 'error-forbidden' ||
|
|
180
201
|
state.status === 'error-not-found' ||
|
|
181
202
|
state.status === 'auth-aborted', showExport: state.status === 'loaded' })] }));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* Painel de jobs da sessão (#34/M2-11) — empilhado a partir da home
|
|
4
4
|
* (`./home.tsx`) ou do detalhe (`./issue-detail.tsx`) com a tecla `t`
|
|
@@ -40,6 +40,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
40
40
|
*/
|
|
41
41
|
import { Box, Text, useInput } from 'ink';
|
|
42
42
|
import { useRef } from 'react';
|
|
43
|
+
import { Gauge } from '../components/gauge.js';
|
|
43
44
|
import { Spinner } from '../components/spinner.js';
|
|
44
45
|
import { glyphs } from '../glyphs.js';
|
|
45
46
|
import { useListNavigation } from '../hooks/use-list-navigation.js';
|
|
@@ -51,7 +52,7 @@ import { useTheme } from '../theme.js';
|
|
|
51
52
|
/** Uma linha do painel: ícone/spinner de status, rótulo, badge textual e, quando selecionada, o hint de cancelamento. */
|
|
52
53
|
function JobRow({ job, selected, theme }) {
|
|
53
54
|
const icon = jobStatusIcon(job.status);
|
|
54
|
-
return (_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: selected ? `${symbols.pointerSmall} ` : ' ' }), job.status === 'processing' ? (_jsx(Spinner, {})) : (_jsx(Text, { color: jobStatusColor(theme, job.status), children: icon })), _jsx(Text, { children: " " }), _jsx(Text, { ...(selected ? { color: theme.primary } : {}), children: job.label }), _jsxs(Text, { color: theme.muted, children: [" [", jobStatusLabel(job.status), "]"] }), selected ? (_jsxs(Text, { color: theme.muted, children: [' ', job.cancelable === true ? '(Ctrl+C cancela)' : '(não cancelável)'] })) : null] }));
|
|
55
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: selected ? `${symbols.pointerSmall} ` : ' ' }), job.status === 'processing' ? (_jsx(Spinner, {})) : (_jsx(Text, { color: jobStatusColor(theme, job.status), children: icon })), _jsx(Text, { children: " " }), _jsx(Text, { ...(selected ? { color: theme.primary } : {}), children: job.label }), _jsxs(Text, { color: theme.muted, children: [" [", jobStatusLabel(job.status), "]"] }), job.progress !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Gauge, { progress: job.progress, color: jobStatusColor(theme, job.status), trackColor: theme.muted })] })) : null, selected ? (_jsxs(Text, { color: theme.muted, children: [' ', job.cancelable === true ? '(Ctrl+C cancela)' : '(não cancelável)'] })) : null] }));
|
|
55
56
|
}
|
|
56
57
|
/**
|
|
57
58
|
* Tela do painel de jobs: lista as operações da sessão (`../job-registry.tsx`),
|
|
@@ -20,7 +20,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
20
20
|
import { useCallback, useRef, useState } from 'react';
|
|
21
21
|
import { Box, Text, useInput } from 'ink';
|
|
22
22
|
import { resolveApiKey, TOOL_NAME, TOOL_VERSION } from '../../../index.js';
|
|
23
|
+
import { bannerLines, selectBanner } from '../banner.js';
|
|
24
|
+
import { GradientBanner } from '../components/gradient-banner.js';
|
|
23
25
|
import { GradientText } from '../components/gradient-text.js';
|
|
26
|
+
import { isUnicodeSupported } from '../glyphs.js';
|
|
27
|
+
import { useTerminalWidth } from '../hooks/use-terminal-width.js';
|
|
24
28
|
import { useEnvFallbackAllowed } from '../instance.js';
|
|
25
29
|
import { useNavigation } from '../navigation.js';
|
|
26
30
|
import { useTheme } from '../theme.js';
|
|
@@ -37,6 +41,10 @@ function instanceFromEnv(env) {
|
|
|
37
41
|
export function WelcomeScreen() {
|
|
38
42
|
const { navigate } = useNavigation();
|
|
39
43
|
const theme = useTheme();
|
|
44
|
+
// Banner responsivo: a arte só entra quando cabe na largura atual e o terminal
|
|
45
|
+
// renderiza os blocos Unicode (ver ../banner.ts).
|
|
46
|
+
const variant = selectBanner(useTerminalWidth(), isUnicodeSupported());
|
|
47
|
+
const gradient = theme.gradient ?? [theme.primary];
|
|
40
48
|
const [checking, setChecking] = useState(false);
|
|
41
49
|
// Handlers ESTÁVEIS (useCallback+refs) — mesmo padrão do TextInput/app.tsx:
|
|
42
50
|
// identidade nova por render des/re-subscreve o useInput e perde teclas.
|
|
@@ -91,5 +99,5 @@ export function WelcomeScreen() {
|
|
|
91
99
|
}
|
|
92
100
|
}, []);
|
|
93
101
|
useInput(handleInput);
|
|
94
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(GradientText, { colors:
|
|
102
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [variant === 'plain' ? (_jsx(GradientText, { colors: gradient, children: TOOL_NAME })) : (_jsx(GradientBanner, { lines: bannerLines(variant), colors: gradient })), _jsxs(Text, { color: theme.muted, children: [variant === 'plain' ? '' : `${TOOL_NAME} `, "v", TOOL_VERSION] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Contexto completo de issues do Redmine, pronto para qualquer LLM." }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.muted, children: ["Pressione ", _jsx(Text, { color: theme.accent, children: "Enter" }), " para continuar,", ' ', _jsx(Text, { color: theme.accent, children: "?" }), " para atalhos,", ' ', _jsx(Text, { color: theme.accent, children: "d" }), " para o doctor,", ' ', _jsx(Text, { color: theme.accent, children: "c" }), " para configura\u00E7\u00E3o,", ' ', _jsx(Text, { color: theme.accent, children: "a" }), " para apar\u00EAncia,", ' ', _jsx(Text, { color: theme.accent, children: "q" }), " para sair."] }) })] }));
|
|
95
103
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quebra de linha por LARGURA para as viewports roláveis da TUI.
|
|
3
|
+
*
|
|
4
|
+
* `../components/scroll-view.tsx` mostra `height` ITENS do array `lines` e
|
|
5
|
+
* assume que cada item ocupa UMA linha na tela. Texto vindo do Redmine não
|
|
6
|
+
* respeita isso: a descrição de um chamado costuma ter parágrafos de centenas
|
|
7
|
+
* de caracteres, o Ink os quebra sozinho em várias linhas visuais, e a conta do
|
|
8
|
+
* viewport passa a errar — o conteúdo escapa da janela, o começo das frases
|
|
9
|
+
* some e a moldura da aplicação aparece em pedaços no meio do texto.
|
|
10
|
+
*
|
|
11
|
+
* Quebrando ANTES de montar as linhas, cada item volta a ser uma linha visual e
|
|
12
|
+
* o ScrollView fica correto sem precisar saber medir texto.
|
|
13
|
+
*
|
|
14
|
+
* A medida é em code points (`[...text]`), a mesma convenção de `./truncate.ts`:
|
|
15
|
+
* suficiente para o conteúdo latino dos chamados e sem trazer dependência de
|
|
16
|
+
* largura de glifo (CJK/emoji contariam 2 colunas).
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Quebra um parágrafo em linhas de no máximo `width` colunas, sem cortar
|
|
20
|
+
* palavras (exceto quando a palavra sozinha excede a largura).
|
|
21
|
+
*
|
|
22
|
+
* @param text - Uma linha lógica (sem `\n`).
|
|
23
|
+
* @param width - Largura disponível em colunas; `<= 0` devolve o texto inteiro
|
|
24
|
+
* numa linha só (não há o que quebrar de forma útil).
|
|
25
|
+
* @returns As linhas resultantes; nunca vazio — texto vazio devolve `['']` para
|
|
26
|
+
* preservar a linha em branco do original.
|
|
27
|
+
* @example
|
|
28
|
+
* wrapLine('uma frase bem comprida', 10); // ['uma frase', 'bem', 'comprida']
|
|
29
|
+
*/
|
|
30
|
+
export declare function wrapLine(text: string, width: number): string[];
|
|
31
|
+
/**
|
|
32
|
+
* Quebra um texto multi-linha, preservando as linhas em branco do original.
|
|
33
|
+
*
|
|
34
|
+
* @param text - Texto com `\n` (descrição de issue, nota de journal).
|
|
35
|
+
* @param width - Largura disponível em colunas.
|
|
36
|
+
* @returns Todas as linhas já ajustadas à largura.
|
|
37
|
+
*/
|
|
38
|
+
export declare function wrapText(text: string, width: number): string[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quebra de linha por LARGURA para as viewports roláveis da TUI.
|
|
3
|
+
*
|
|
4
|
+
* `../components/scroll-view.tsx` mostra `height` ITENS do array `lines` e
|
|
5
|
+
* assume que cada item ocupa UMA linha na tela. Texto vindo do Redmine não
|
|
6
|
+
* respeita isso: a descrição de um chamado costuma ter parágrafos de centenas
|
|
7
|
+
* de caracteres, o Ink os quebra sozinho em várias linhas visuais, e a conta do
|
|
8
|
+
* viewport passa a errar — o conteúdo escapa da janela, o começo das frases
|
|
9
|
+
* some e a moldura da aplicação aparece em pedaços no meio do texto.
|
|
10
|
+
*
|
|
11
|
+
* Quebrando ANTES de montar as linhas, cada item volta a ser uma linha visual e
|
|
12
|
+
* o ScrollView fica correto sem precisar saber medir texto.
|
|
13
|
+
*
|
|
14
|
+
* A medida é em code points (`[...text]`), a mesma convenção de `./truncate.ts`:
|
|
15
|
+
* suficiente para o conteúdo latino dos chamados e sem trazer dependência de
|
|
16
|
+
* largura de glifo (CJK/emoji contariam 2 colunas).
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Quebra um parágrafo em linhas de no máximo `width` colunas, sem cortar
|
|
20
|
+
* palavras (exceto quando a palavra sozinha excede a largura).
|
|
21
|
+
*
|
|
22
|
+
* @param text - Uma linha lógica (sem `\n`).
|
|
23
|
+
* @param width - Largura disponível em colunas; `<= 0` devolve o texto inteiro
|
|
24
|
+
* numa linha só (não há o que quebrar de forma útil).
|
|
25
|
+
* @returns As linhas resultantes; nunca vazio — texto vazio devolve `['']` para
|
|
26
|
+
* preservar a linha em branco do original.
|
|
27
|
+
* @example
|
|
28
|
+
* wrapLine('uma frase bem comprida', 10); // ['uma frase', 'bem', 'comprida']
|
|
29
|
+
*/
|
|
30
|
+
export function wrapLine(text, width) {
|
|
31
|
+
if (width <= 0)
|
|
32
|
+
return [text];
|
|
33
|
+
const chars = [...text];
|
|
34
|
+
if (chars.length <= width)
|
|
35
|
+
return [text];
|
|
36
|
+
const lines = [];
|
|
37
|
+
let current = '';
|
|
38
|
+
const pushCurrent = () => {
|
|
39
|
+
if (current.length > 0) {
|
|
40
|
+
lines.push(current);
|
|
41
|
+
current = '';
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
for (const word of text.split(' ')) {
|
|
45
|
+
const wordLength = [...word].length;
|
|
46
|
+
const currentLength = [...current].length;
|
|
47
|
+
// Palavra maior que a largura (URL, hash, caminho): quebra à força, em
|
|
48
|
+
// pedaços do tamanho da linha — melhor que estourar o viewport.
|
|
49
|
+
if (wordLength > width) {
|
|
50
|
+
pushCurrent();
|
|
51
|
+
let rest = [...word];
|
|
52
|
+
while (rest.length > width) {
|
|
53
|
+
lines.push(rest.slice(0, width).join(''));
|
|
54
|
+
rest = rest.slice(width);
|
|
55
|
+
}
|
|
56
|
+
current = rest.join('');
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (currentLength === 0) {
|
|
60
|
+
current = word;
|
|
61
|
+
}
|
|
62
|
+
else if (currentLength + 1 + wordLength <= width) {
|
|
63
|
+
current = `${current} ${word}`;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
pushCurrent();
|
|
67
|
+
current = word;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
pushCurrent();
|
|
71
|
+
return lines.length > 0 ? lines : [''];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Quebra um texto multi-linha, preservando as linhas em branco do original.
|
|
75
|
+
*
|
|
76
|
+
* @param text - Texto com `\n` (descrição de issue, nota de journal).
|
|
77
|
+
* @param width - Largura disponível em colunas.
|
|
78
|
+
* @returns Todas as linhas já ajustadas à largura.
|
|
79
|
+
*/
|
|
80
|
+
export function wrapText(text, width) {
|
|
81
|
+
return text.split('\n').flatMap((line) => wrapLine(line, width));
|
|
82
|
+
}
|
package/package.json
CHANGED