redmine-context 1.0.0 → 1.2.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.
Files changed (61) hide show
  1. package/README.md +20 -5
  2. package/dist/bundle/journal-detail.d.ts +89 -0
  3. package/dist/bundle/journal-detail.js +260 -0
  4. package/dist/bundle/markdown.d.ts +7 -0
  5. package/dist/bundle/markdown.js +21 -12
  6. package/dist/client/enumerations.d.ts +39 -0
  7. package/dist/client/enumerations.js +76 -0
  8. package/dist/client/index.d.ts +1 -0
  9. package/dist/client/index.js +1 -0
  10. package/dist/fetch-issue-bundle.js +17 -2
  11. package/dist/fetch-issue-search.d.ts +10 -0
  12. package/dist/fetch-issue-search.js +1 -1
  13. package/dist/fetch-last-issues.d.ts +96 -0
  14. package/dist/fetch-last-issues.js +120 -0
  15. package/dist/index.d.ts +6 -1
  16. package/dist/index.js +22 -2
  17. package/dist/normalize/issue.js +32 -3
  18. package/dist/surfaces/cli/commands.d.ts +17 -0
  19. package/dist/surfaces/cli/commands.js +105 -12
  20. package/dist/surfaces/cli/main.js +22 -3
  21. package/dist/surfaces/mcp/server.d.ts +22 -45
  22. package/dist/surfaces/mcp/server.js +67 -56
  23. package/dist/surfaces/mcp/tools.d.ts +109 -0
  24. package/dist/surfaces/mcp/tools.js +93 -0
  25. package/dist/surfaces/tui/app.d.ts +14 -0
  26. package/dist/surfaces/tui/app.js +40 -3
  27. package/dist/surfaces/tui/banner.d.ts +43 -0
  28. package/dist/surfaces/tui/banner.js +93 -0
  29. package/dist/surfaces/tui/components/gauge.d.ts +27 -0
  30. package/dist/surfaces/tui/components/gauge.js +53 -0
  31. package/dist/surfaces/tui/components/gradient-banner.d.ts +13 -0
  32. package/dist/surfaces/tui/components/gradient-banner.js +41 -0
  33. package/dist/surfaces/tui/components/text-input.js +23 -4
  34. package/dist/surfaces/tui/glyphs.d.ts +8 -0
  35. package/dist/surfaces/tui/glyphs.js +8 -0
  36. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +7 -1
  37. package/dist/surfaces/tui/hooks/use-issue-detail.js +15 -2
  38. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +25 -2
  39. package/dist/surfaces/tui/hooks/use-issue-search.js +14 -2
  40. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +8 -0
  41. package/dist/surfaces/tui/hooks/use-list-navigation.js +12 -1
  42. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +9 -0
  43. package/dist/surfaces/tui/hooks/use-my-issues.js +6 -2
  44. package/dist/surfaces/tui/hooks/use-status-options.d.ts +38 -0
  45. package/dist/surfaces/tui/hooks/use-status-options.js +86 -0
  46. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +16 -0
  47. package/dist/surfaces/tui/hooks/use-terminal-width.js +15 -0
  48. package/dist/surfaces/tui/hooks/use-typing-guard.d.ts +19 -0
  49. package/dist/surfaces/tui/hooks/use-typing-guard.js +58 -0
  50. package/dist/surfaces/tui/list-window.d.ts +39 -0
  51. package/dist/surfaces/tui/list-window.js +41 -0
  52. package/dist/surfaces/tui/screens/export.js +6 -2
  53. package/dist/surfaces/tui/screens/home.js +161 -33
  54. package/dist/surfaces/tui/screens/issue-detail.js +55 -16
  55. package/dist/surfaces/tui/screens/jobs.js +3 -2
  56. package/dist/surfaces/tui/screens/welcome.js +9 -1
  57. package/dist/surfaces/tui/status-color.d.ts +21 -0
  58. package/dist/surfaces/tui/status-color.js +33 -0
  59. package/dist/surfaces/tui/wrap.d.ts +38 -0
  60. package/dist/surfaces/tui/wrap.js +82 -0
  61. 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
+ }
@@ -30,6 +30,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
30
30
  */
31
31
  import { useCallback, useRef } from 'react';
32
32
  import { Text, useInput } from 'ink';
33
+ import { useTypingGuard } from '../hooks/use-typing-guard.js';
33
34
  import { useTheme } from '../theme.js';
34
35
  import { truncate, truncateStart } from '../truncate.js';
35
36
  /**
@@ -79,21 +80,39 @@ export function TextInput({ value, onChange, onSubmit, mask, placeholder, isActi
79
80
  return;
80
81
  }
81
82
  if (key.backspace || key.delete) {
82
- onChangeRef.current(valueRef.current.slice(0, -1));
83
+ // A ref avança JUNTO com a emissão: sincronizá-la só no render (abaixo)
84
+ // faz duas teclas chegadas antes do commit do React partirem do mesmo
85
+ // valor velho — a última vencia e as anteriores se perdiam. Digitar uma
86
+ // URL em velocidade normal corrompia o campo.
87
+ const next = valueRef.current.slice(0, -1);
88
+ valueRef.current = next;
89
+ onChangeRef.current(next);
83
90
  return;
84
91
  }
85
- // Reason: tecla ÚNICA reservada por um controle da tela pai (ex.: "f"
86
- // cicla o filtro de status na busca da home, M2-07/#30) — ignorada
92
+ // Reason: tecla ÚNICA reservada por um controle da tela pai ignorada
87
93
  // aqui para não virar texto digitado; a tela pai trata o mesmo evento
88
94
  // via seu próprio `useInput()`. Só bloqueia o caractere isolado, não
89
95
  // uma sequência colada maior que o contenha.
96
+ //
97
+ // Sem consumidor em produção hoje: o caso original ("f" ciclando o filtro
98
+ // da home) foi resolvido pelo caminho inverso — os ATALHOS é que se
99
+ // suspendem enquanto há campo ativo (`../hooks/use-typing-guard.ts`), o
100
+ // que cobre qualquer tecla sem a tela precisar declarar uma lista. A prop
101
+ // segue disponível e testada para o caso de uma tela precisar reservar
102
+ // uma tecla ESPECÍFICA mantendo os demais atalhos vivos.
90
103
  if (reservedCharsRef.current.includes(input)) {
91
104
  return;
92
105
  }
93
106
  if (input.length > 0) {
94
- onChangeRef.current(valueRef.current + input);
107
+ const next = valueRef.current + input;
108
+ valueRef.current = next;
109
+ onChangeRef.current(next);
95
110
  }
96
111
  }, []);
112
+ // Suspende os atalhos de LETRA enquanto este campo captura teclado: o Ink
113
+ // entrega a tecla a todos os handlers, então sem isto um `q` digitado aqui
114
+ // dispararia o atalho global de sair (ver ../hooks/use-typing-guard.ts).
115
+ useTypingGuard(isActive);
97
116
  useInput(handleInput, { isActive });
98
117
  // Reason: cursor em bloco simples (inversão de cor, sem literal — não
99
118
  // conta como cor hardcoded) só quando o campo está ativo, reforçando
@@ -27,10 +27,18 @@ export interface Glyphs {
27
27
  readonly arrowUp: string;
28
28
  /** Seta "para baixo" das dicas de navegação. */
29
29
  readonly arrowDown: string;
30
+ /** Seta "para a esquerda" (página anterior nas listas longas). */
31
+ readonly arrowLeft: string;
32
+ /** Seta "para a direita" (próxima página nas listas longas). */
33
+ readonly arrowRight: string;
30
34
  /** Caractere da máscara de senha/api_key (`components/text-input.tsx`). */
31
35
  readonly maskBullet: string;
32
36
  /** Frames do spinner (`components/spinner.tsx`). */
33
37
  readonly spinnerFrames: readonly string[];
38
+ /** Célula PREENCHIDA da barra de progresso (`components/gauge.tsx`). */
39
+ readonly gaugeFull: string;
40
+ /** Célula VAZIA da barra de progresso (`components/gauge.tsx`). */
41
+ readonly gaugeEmpty: string;
34
42
  }
35
43
  /** Glyphs Unicode — terminais modernos (Windows Terminal, iTerm, etc.). */
36
44
  export declare const UNICODE_GLYPHS: Glyphs;
@@ -58,8 +58,12 @@ export const UNICODE_GLYPHS = {
58
58
  emptyPlaceholder: '—',
59
59
  arrowUp: '↑',
60
60
  arrowDown: '↓',
61
+ arrowLeft: '←',
62
+ arrowRight: '→',
61
63
  maskBullet: '•',
62
64
  spinnerFrames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
65
+ gaugeFull: '█',
66
+ gaugeEmpty: '░',
63
67
  };
64
68
  /** Fallback ASCII puro — terminal legado do Windows (sem mojibake). */
65
69
  export const ASCII_GLYPHS = {
@@ -68,8 +72,12 @@ export const ASCII_GLYPHS = {
68
72
  emptyPlaceholder: '-',
69
73
  arrowUp: '^',
70
74
  arrowDown: 'v',
75
+ arrowLeft: '<',
76
+ arrowRight: '>',
71
77
  maskBullet: '*',
72
78
  spinnerFrames: ['|', '/', '-', '\\'],
79
+ gaugeFull: '#',
80
+ gaugeEmpty: '-',
73
81
  };
74
82
  /**
75
83
  * 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
- setState({ status: 'loaded', issue: normalize(payload) });
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)
@@ -1,8 +1,17 @@
1
- import { fetchIssueSearch, resolveApiKey } from '../../../index.js';
1
+ import { fetchIssueSearch, resolveApiKey, type SearchListItem } from '../../../index.js';
2
2
  /** Debounce (ms) default aplicado a `query` antes de disparar a busca. */
3
3
  export declare const DEFAULT_DEBOUNCE_MS = 300;
4
4
  /** Filtro rápido de status (tecla `f` cicla entre os três, ver `../screens/home.tsx`). */
5
- export type SearchStatusFilter = 'open' | 'closed' | 'all';
5
+ /**
6
+ * Filtro de status da home/busca.
7
+ *
8
+ * `'all'`/`'open'`/`'closed'` são os agregados do Redmine; um NÚMERO é o id de
9
+ * um status específico da instância (`/issue_statuses.json`). Os agregados
10
+ * sozinhos não serviam: uma instância real tem uma dezena de status (Nova,
11
+ * Fila, Estimativa, Atribuída, Em Andamento, Validação...) e todos eles são
12
+ * "abertos" — alternar aberto/fechado devolvia exatamente a mesma lista.
13
+ */
14
+ export type SearchStatusFilter = 'open' | 'closed' | 'all' | number;
6
15
  /**
7
16
  * Estado da busca, consumido por `../screens/home.tsx`. Mesmo vocabulário de
8
17
  * `MyIssuesState` (`use-my-issues.ts`) + um `idle` inicial próprio: a busca
@@ -16,7 +25,10 @@ export type IssueSearchState = {
16
25
  status: 'loading';
17
26
  } | {
18
27
  status: 'loaded';
28
+ /** Markdown do bundle (com as fences) — mantido para quem precisar dele. */
19
29
  content: string;
30
+ /** Itens ESTRUTURADOS: é o que a tela renderiza (ver ../screens/home.tsx). */
31
+ items: readonly SearchListItem[];
20
32
  count: number;
21
33
  degraded: boolean;
22
34
  warnings: string[];
@@ -55,6 +67,17 @@ export interface UseIssueSearchResult {
55
67
  */
56
68
  clear: () => void;
57
69
  }
70
+ /**
71
+ * Mapeia o filtro rápido de status para o `status_id` de `/issues.json`
72
+ * (`*` = todas).
73
+ *
74
+ * Exportada porque a LISTA da home (`./use-my-issues.ts`) aplica o mesmo filtro
75
+ * que a busca — duas traduções separadas divergiriam.
76
+ *
77
+ * @param filter - Filtro rápido escolhido na tela.
78
+ * @returns O valor de `status_id` para a query.
79
+ */
80
+ export declare function statusIdFor(filter: SearchStatusFilter): string;
58
81
  /**
59
82
  * Busca issues (filtros + full-text best-effort) via o core, com debounce de
60
83
  * digitação e o filtro rápido de status.
@@ -34,8 +34,19 @@ import { useEnvFallbackAllowed } from '../instance.js';
34
34
  import { ReAuthAbortedError, useAuthGuard } from './use-auth-guard.js';
35
35
  /** Debounce (ms) default aplicado a `query` antes de disparar a busca. */
36
36
  export const DEFAULT_DEBOUNCE_MS = 300;
37
- /** Mapeia o filtro rápido de status para o `status_id` de `/issues.json` (`*` = todas). */
38
- function statusIdFor(filter) {
37
+ /**
38
+ * Mapeia o filtro rápido de status para o `status_id` de `/issues.json`
39
+ * (`*` = todas).
40
+ *
41
+ * Exportada porque a LISTA da home (`./use-my-issues.ts`) aplica o mesmo filtro
42
+ * que a busca — duas traduções separadas divergiriam.
43
+ *
44
+ * @param filter - Filtro rápido escolhido na tela.
45
+ * @returns O valor de `status_id` para a query.
46
+ */
47
+ export function statusIdFor(filter) {
48
+ if (typeof filter === 'number')
49
+ return String(filter);
39
50
  if (filter === 'open')
40
51
  return 'open';
41
52
  if (filter === 'closed')
@@ -124,6 +135,7 @@ export function useIssueSearch(query, statusFilter, options = {}) {
124
135
  setState({
125
136
  status: 'loaded',
126
137
  content: result.content,
138
+ items: result.items,
127
139
  count: result.count,
128
140
  degraded: result.degraded,
129
141
  warnings: result.warnings,
@@ -14,6 +14,14 @@ export interface UseListNavigationOptions {
14
14
  * inline da home, M2-07/#30). Default: `true`.
15
15
  */
16
16
  isActive?: boolean;
17
+ /**
18
+ * Itens saltados por uma "página" (setas ESQUERDA/DIREITA e PageUp/PageDown).
19
+ *
20
+ * Navegar item a item não escala: uma lista filtrada por status pode ter
21
+ * centenas de entradas. A tela deve passar a ALTURA da sua janela visível,
22
+ * para que uma página corresponda a uma tela cheia. Default: 10.
23
+ */
24
+ pageSize?: number;
17
25
  /**
18
26
  * Cursor inicial — default `0`. Aplicado assim que `itemCount` deixa de
19
27
  * ser `0` pela primeira vez (clampado); mudanças subsequentes NÃO
@@ -27,7 +27,7 @@ import { useEffect, useRef, useState } from 'react';
27
27
  * });
28
28
  */
29
29
  export function useListNavigation(itemCount, options = {}) {
30
- const { onSelect, isActive = true, initialIndex } = options;
30
+ const { onSelect, isActive = true, initialIndex, pageSize = 10 } = options;
31
31
  // Ref (não dep de efeito): a aplicação do `initialIndex` deve acontecer uma
32
32
  // única vez, na primeira vez que a lista deixa de estar vazia — mesmo que
33
33
  // `initialIndex` mude entre renders (ex.: o contexto de origem re-renderiza
@@ -54,6 +54,17 @@ export function useListNavigation(itemCount, options = {}) {
54
54
  setSelectedIndex((current) => (current + 1) % itemCount);
55
55
  return;
56
56
  }
57
+ // Página: ao contrário de ↑/↓, NÃO dá a volta — saltar do topo para o fim
58
+ // da lista desorienta. Para nas bordas, como em qualquer paginador.
59
+ const page = Math.max(1, pageSize);
60
+ if (key.leftArrow || key.pageUp) {
61
+ setSelectedIndex((current) => Math.max(0, current - page));
62
+ return;
63
+ }
64
+ if (key.rightArrow || key.pageDown) {
65
+ setSelectedIndex((current) => Math.min(itemCount - 1, current + page));
66
+ return;
67
+ }
57
68
  if (key.return) {
58
69
  onSelect?.(selectedIndex);
59
70
  }
@@ -1,4 +1,5 @@
1
1
  import { createHttpClient, listIssues, resolveApiKey } from '../../../index.js';
2
+ import { type SearchStatusFilter } from './use-issue-search.js';
2
3
  /** Issue resumida exibida numa linha da lista da home — só os campos usados por `IssueRow`. */
3
4
  export interface MyIssue {
4
5
  /** Id numérico da issue (`#id` na linha). */
@@ -37,6 +38,14 @@ export type MyIssuesState = {
37
38
  };
38
39
  /** Dependências injetáveis do hook — todas opcionais, com defaults de produção via o core. */
39
40
  export interface UseMyIssuesOptions {
41
+ /**
42
+ * Filtro rápido de status (`f` na home). Default `'all'`.
43
+ *
44
+ * A LISTA precisa respeitá-lo: antes, o filtro só alimentava a busca — cujos
45
+ * resultados só aparecem com a busca ABERTA —, então ciclar o status não
46
+ * mudava nada na tela.
47
+ */
48
+ statusFilter?: SearchStatusFilter;
40
49
  /** Ambiente consultado para `REDMINE_URL`; default `process.env`. */
41
50
  env?: NodeJS.ProcessEnv;
42
51
  /** Resolve a api_key pela cascata M2; default `resolveApiKey` do core. */
@@ -32,6 +32,7 @@ import { useCallback, useEffect, useState } from 'react';
32
32
  import { createHttpClient, listIssues, resolveApiKey, RedmineForbiddenError, } from '../../../index.js';
33
33
  import { useEnvFallbackAllowed } from '../instance.js';
34
34
  import { ReAuthAbortedError, useAuthGuard } from './use-auth-guard.js';
35
+ import { statusIdFor } from './use-issue-search.js';
35
36
  /** Estreita `unknown` para um objeto indexável, ou `undefined` se não for. */
36
37
  function asRecord(value) {
37
38
  return typeof value === 'object' && value !== null ? value : undefined;
@@ -65,6 +66,7 @@ function instanceFromEnv(env) {
65
66
  * const { state, retry } = useMyIssues(); // deps de produção (process.env, core real)
66
67
  */
67
68
  export function useMyIssues(options = {}) {
69
+ const statusFilter = options.statusFilter ?? 'all';
68
70
  const env = options.env ?? process.env;
69
71
  const resolve = options.resolveApiKey ?? resolveApiKey;
70
72
  const buildClient = options.createHttpClient ?? createHttpClient;
@@ -109,7 +111,9 @@ export function useMyIssues(options = {}) {
109
111
  // Fix do review #120: envolvida por `guard()` — em 401, a Promise só
110
112
  // resolve após o re-login ter sucesso e a busca ser refeita
111
113
  // automaticamente; o estado do hook permanece `loading` enquanto isso.
112
- const raw = await guard(() => fetchIssues(http, { filters: { assigned_to_id: 'me' } }));
114
+ const raw = await guard(() => fetchIssues(http, {
115
+ filters: { assigned_to_id: 'me', status_id: statusIdFor(statusFilter) },
116
+ }));
113
117
  if (cancelled)
114
118
  return;
115
119
  const issues = raw.map(toMyIssue).filter((issue) => issue !== undefined);
@@ -146,6 +150,6 @@ export function useMyIssues(options = {}) {
146
150
  // entre renders com as deps de produção (defaults do módulo, ou
147
151
  // `process.env`, ou a identidade estável de `useAuthGuard().guard`) — o
148
152
  // efeito só precisa refazer a busca quando `reloadToken` muda (retry).
149
- }, [env, allowEnvFallback, resolve, buildClient, fetchIssues, guard, reloadToken]);
153
+ }, [statusFilter, env, allowEnvFallback, resolve, buildClient, fetchIssues, guard, reloadToken]);
150
154
  return { state, retry };
151
155
  }
@@ -0,0 +1,38 @@
1
+ import { createHttpClient, fetchEnumerations, resolveApiKey } from '../../../index.js';
2
+ import type { SearchStatusFilter } from './use-issue-search.js';
3
+ /** Uma opção do seletor: o valor do filtro + o rótulo exibido. */
4
+ export interface StatusOption {
5
+ /** Valor aplicado ao filtro. */
6
+ readonly value: SearchStatusFilter;
7
+ /** Rótulo legível (nome do status na instância, ou o agregado). */
8
+ readonly label: string;
9
+ }
10
+ /** Opções injetáveis (testes não tocam a rede). */
11
+ export interface UseStatusOptionsOptions {
12
+ /** Ambiente consultado para `REDMINE_URL`; default `process.env`. */
13
+ env?: NodeJS.ProcessEnv;
14
+ /** Resolve a api_key pela cascata; default `resolveApiKey` do core. */
15
+ resolveApiKey?: typeof resolveApiKey;
16
+ /** Constrói o client HTTP; default `createHttpClient` do core. */
17
+ createHttpClient?: typeof createHttpClient;
18
+ /** Busca as enumerações; default `fetchEnumerations` do core. */
19
+ fetchEnumerations?: typeof fetchEnumerations;
20
+ }
21
+ /**
22
+ * Opções do filtro de status: agregados + os status reais da instância.
23
+ *
24
+ * @param options - Dependências injetáveis (ver {@link UseStatusOptionsOptions}).
25
+ * @returns A lista de opções; só os agregados enquanto carrega ou se falhar.
26
+ * @example
27
+ * const options = useStatusOptions();
28
+ * // [{ value: 'all', label: 'Todas' }, ..., { value: 7, label: 'Atribuída' }]
29
+ */
30
+ export declare function useStatusOptions(options?: UseStatusOptionsOptions): readonly StatusOption[];
31
+ /**
32
+ * Rótulo de um filtro, resolvido contra as opções carregadas.
33
+ *
34
+ * @param options - Opções disponíveis.
35
+ * @param filter - Filtro corrente.
36
+ * @returns O rótulo da opção, ou `#id` se o status não constar (degradação).
37
+ */
38
+ export declare function statusFilterLabel(options: readonly StatusOption[], filter: SearchStatusFilter): string;