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,86 @@
1
+ /**
2
+ * Opções do filtro de status da home (#30, revisto).
3
+ *
4
+ * O ciclo `todas → abertas → fechadas` não servia a uma instância real: todos os
5
+ * status de trabalho (Nova, Fila, Estimativa, Atribuída, Em Andamento,
6
+ * Validação…) são ABERTOS, então alternar entre "todas" e "abertas" devolvia a
7
+ * mesma lista e o filtro parecia quebrado.
8
+ *
9
+ * Este hook carrega os status DA INSTÂNCIA (`/issue_statuses.json`, já memoizado
10
+ * por `fetchEnumerations`) e os oferece como opções, com os dois agregados úteis
11
+ * no topo. Degrada em silêncio: sem permissão ou sem rede, sobram os agregados —
12
+ * o mesmo comportamento de antes, nunca um erro na tela.
13
+ */
14
+ import { useEffect, useState } from 'react';
15
+ import { createHttpClient, fetchEnumerations, resolveApiKey, } from '../../../index.js';
16
+ import { useEnvFallbackAllowed } from '../instance.js';
17
+ /** Agregados sempre disponíveis, mesmo sem as enumerações da instância. */
18
+ const BASE_OPTIONS = [
19
+ { value: 'all', label: 'Todas' },
20
+ { value: 'open', label: 'Abertas' },
21
+ { value: 'closed', label: 'Fechadas' },
22
+ ];
23
+ /** Lê `REDMINE_URL` do ambiente, tratando string vazia como ausente. */
24
+ function instanceFromEnv(env) {
25
+ const value = env.REDMINE_URL;
26
+ return value !== undefined && value.length > 0 ? value : undefined;
27
+ }
28
+ /**
29
+ * Opções do filtro de status: agregados + os status reais da instância.
30
+ *
31
+ * @param options - Dependências injetáveis (ver {@link UseStatusOptionsOptions}).
32
+ * @returns A lista de opções; só os agregados enquanto carrega ou se falhar.
33
+ * @example
34
+ * const options = useStatusOptions();
35
+ * // [{ value: 'all', label: 'Todas' }, ..., { value: 7, label: 'Atribuída' }]
36
+ */
37
+ export function useStatusOptions(options = {}) {
38
+ const env = options.env ?? process.env;
39
+ const resolve = options.resolveApiKey ?? resolveApiKey;
40
+ const buildClient = options.createHttpClient ?? createHttpClient;
41
+ const loadEnumerations = options.fetchEnumerations ?? fetchEnumerations;
42
+ const allowEnvFallback = useEnvFallbackAllowed();
43
+ const [statuses, setStatuses] = useState([]);
44
+ useEffect(() => {
45
+ let cancelled = false;
46
+ const instanceUrl = instanceFromEnv(env);
47
+ if (instanceUrl === undefined)
48
+ return undefined;
49
+ void (async () => {
50
+ try {
51
+ const cascade = { env, allowEnvFallback };
52
+ const apiKey = await resolve(instanceUrl, cascade);
53
+ if (apiKey === undefined || cancelled)
54
+ return;
55
+ const enums = await loadEnumerations(buildClient({ baseUrl: instanceUrl, apiKey }), instanceUrl);
56
+ if (cancelled)
57
+ return;
58
+ // Ordem estável por id: a lista não pode dançar entre renders.
59
+ setStatuses([...enums.status.entries()]
60
+ .sort(([a], [b]) => a - b)
61
+ .map(([id, label]) => ({ value: id, label })));
62
+ }
63
+ catch {
64
+ // Enriquecimento, não dado essencial: sem os status da instância o
65
+ // seletor segue com os agregados.
66
+ }
67
+ })();
68
+ return () => {
69
+ cancelled = true;
70
+ };
71
+ }, [env, allowEnvFallback, resolve, buildClient, loadEnumerations]);
72
+ return statuses.length === 0 ? BASE_OPTIONS : [...BASE_OPTIONS, ...statuses];
73
+ }
74
+ /**
75
+ * Rótulo de um filtro, resolvido contra as opções carregadas.
76
+ *
77
+ * @param options - Opções disponíveis.
78
+ * @param filter - Filtro corrente.
79
+ * @returns O rótulo da opção, ou `#id` se o status não constar (degradação).
80
+ */
81
+ export function statusFilterLabel(options, filter) {
82
+ const found = options.find((option) => option.value === filter);
83
+ if (found !== undefined)
84
+ return found.label;
85
+ return typeof filter === 'number' ? `#${filter}` : filter;
86
+ }
@@ -34,6 +34,22 @@ export declare function TerminalWidthProvider({ width, children }: {
34
34
  * consumidor (ex.: cada linha da lista) assinar o próprio `resize` — o que
35
35
  * estourava o limite de 10 listeners do EventEmitter (MaxListenersExceededWarning).
36
36
  */
37
+ /**
38
+ * Sobrescreve a ALTURA disponível para as telas abaixo.
39
+ *
40
+ * Usado pelo shell (`../app.tsx`) para descontar as linhas que a moldura da
41
+ * aplicação consome: as telas pedem `useTerminalHeight()` e devem receber o
42
+ * espaço que sobra, sem precisar saber que existe uma moldura — do contrário
43
+ * cada tela carregaria uma constante acoplada ao desenho do shell (e os testes,
44
+ * que montam telas sem moldura, veriam um valor errado).
45
+ *
46
+ * @param props.height - Altura disponível, em linhas.
47
+ * @param props.children - Subárvore que passa a enxergar essa altura.
48
+ */
49
+ export declare function TerminalHeightProvider({ height, children }: {
50
+ height: number;
51
+ children: ReactNode;
52
+ }): import("react").JSX.Element;
37
53
  export declare function TerminalSizeProvider({ children }: {
38
54
  children: ReactNode;
39
55
  }): import("react").JSX.Element;
@@ -47,6 +47,21 @@ export function TerminalWidthProvider({ width, children }) {
47
47
  * consumidor (ex.: cada linha da lista) assinar o próprio `resize` — o que
48
48
  * estourava o limite de 10 listeners do EventEmitter (MaxListenersExceededWarning).
49
49
  */
50
+ /**
51
+ * Sobrescreve a ALTURA disponível para as telas abaixo.
52
+ *
53
+ * Usado pelo shell (`../app.tsx`) para descontar as linhas que a moldura da
54
+ * aplicação consome: as telas pedem `useTerminalHeight()` e devem receber o
55
+ * espaço que sobra, sem precisar saber que existe uma moldura — do contrário
56
+ * cada tela carregaria uma constante acoplada ao desenho do shell (e os testes,
57
+ * que montam telas sem moldura, veriam um valor errado).
58
+ *
59
+ * @param props.height - Altura disponível, em linhas.
60
+ * @param props.children - Subárvore que passa a enxergar essa altura.
61
+ */
62
+ export function TerminalHeightProvider({ height, children }) {
63
+ return _jsx(TerminalHeightContext.Provider, { value: height, children: children });
64
+ }
50
65
  export function TerminalSizeProvider({ children }) {
51
66
  const [size, setSize] = useState(() => ({ width: readProcessColumns(), height: readProcessRows() }));
52
67
  useEffect(() => {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Há algum campo de texto ativo agora?
3
+ *
4
+ * Chamado pelos handlers de atalho ANTES de reagir a uma tecla de letra.
5
+ *
6
+ * @returns `true` enquanto qualquer `TextInput` ativo estiver montado.
7
+ */
8
+ export declare function isTyping(): boolean;
9
+ /** Zera o contador — para os testes não vazarem estado entre casos. */
10
+ export declare function resetTypingGuard(): void;
11
+ /**
12
+ * Registra um campo de texto como ativo enquanto `active` for `true`.
13
+ *
14
+ * @param active - `true` enquanto o campo captura teclado.
15
+ * @example
16
+ * // dentro de um componente de input:
17
+ * useTypingGuard(isActive);
18
+ */
19
+ export declare function useTypingGuard(active: boolean): void;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Guarda de DIGITAÇÃO para os atalhos de letra.
3
+ *
4
+ * O Ink entrega cada tecla a TODOS os `useInput` registrados na árvore — um
5
+ * campo de texto ativo não "consome" a tecla. Sem esta guarda, qualquer atalho
6
+ * global de letra dispara no meio da digitação: `q` (sair) fecha a TUI enquanto
7
+ * se digita a URL da instância no onboarding (`https://redmine.qualquer...`) ou
8
+ * uma senha que contenha a letra.
9
+ *
10
+ * As telas que já sabem quando estão em modo de texto (a busca da home) checam
11
+ * o próprio estado; este módulo cobre o caso GERAL — qualquer `TextInput` ativo
12
+ * em qualquer tela suspende os atalhos de letra enquanto durar.
13
+ *
14
+ * Mesmo desenho de `./use-escape-interceptor.ts`: estado mutável em nível de
15
+ * módulo, não Context/Provider — a TUI sustenta uma tela por vez e um Provider
16
+ * seria complexidade sem benefício. Aqui é um CONTADOR (não um booleano) porque
17
+ * uma tela pode montar mais de um campo (ex.: usuário + senha) e a ordem de
18
+ * montagem/desmontagem entre eles não é garantida.
19
+ *
20
+ * Atalhos com modificador (`Ctrl+C`) continuam livres: eles não colidem com
21
+ * texto. `Command+<letra>` não é alternativa em TUI — no macOS o terminal
22
+ * intercepta antes de a tecla chegar à aplicação.
23
+ */
24
+ import { useEffect } from 'react';
25
+ /** Quantidade de campos de texto ATIVOS no momento. */
26
+ let activeInputs = 0;
27
+ /**
28
+ * Há algum campo de texto ativo agora?
29
+ *
30
+ * Chamado pelos handlers de atalho ANTES de reagir a uma tecla de letra.
31
+ *
32
+ * @returns `true` enquanto qualquer `TextInput` ativo estiver montado.
33
+ */
34
+ export function isTyping() {
35
+ return activeInputs > 0;
36
+ }
37
+ /** Zera o contador — para os testes não vazarem estado entre casos. */
38
+ export function resetTypingGuard() {
39
+ activeInputs = 0;
40
+ }
41
+ /**
42
+ * Registra um campo de texto como ativo enquanto `active` for `true`.
43
+ *
44
+ * @param active - `true` enquanto o campo captura teclado.
45
+ * @example
46
+ * // dentro de um componente de input:
47
+ * useTypingGuard(isActive);
48
+ */
49
+ export function useTypingGuard(active) {
50
+ useEffect(() => {
51
+ if (!active)
52
+ return undefined;
53
+ activeInputs += 1;
54
+ return () => {
55
+ activeInputs = Math.max(0, activeInputs - 1);
56
+ };
57
+ }, [active]);
58
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Janela visível de uma lista longa, seguindo o cursor.
3
+ *
4
+ * A home renderizava TODAS as issues de uma vez. Enquanto a lista era curta
5
+ * (as abertas de uma pessoa) isso passava; filtrando por um status com centenas
6
+ * de itens, a lista estoura a altura do terminal, o rodapé é empurrado para fora
7
+ * e o cursor — que começa no topo — some da tela: "cadê meu cursor".
8
+ *
9
+ * Esta é a contraparte de `./components/scroll-view.tsx` para listas
10
+ * SELECIONÁVEIS: lá o deslocamento é dirigido pelo teclado sobre conteúdo
11
+ * estático; aqui ele acompanha o índice selecionado, que é quem manda.
12
+ *
13
+ * Sem estado próprio de propósito: a janela é função do (total, selecionado,
14
+ * altura). Guardar um offset exigiria sincronizá-lo com a seleção, com a troca
15
+ * de filtro e com o redimensionamento do terminal — três fontes de divergência.
16
+ */
17
+ /** Intervalo `[start, end)` de itens a renderizar. */
18
+ export interface ListWindow {
19
+ /** Índice do primeiro item visível. */
20
+ readonly start: number;
21
+ /** Índice logo APÓS o último visível (exclusivo, pronto para `slice`). */
22
+ readonly end: number;
23
+ }
24
+ /**
25
+ * Calcula a fatia visível mantendo o item selecionado dentro dela.
26
+ *
27
+ * O cursor é mantido ao MEIO da janela sempre que possível; nas pontas a janela
28
+ * gruda no início ou no fim, para não sobrar espaço vazio.
29
+ *
30
+ * @param total - Quantidade de itens da lista.
31
+ * @param selected - Índice selecionado (fora da faixa é fixado nela).
32
+ * @param height - Linhas disponíveis; `<= 0` devolve uma janela vazia.
33
+ * @returns O intervalo `[start, end)` para `slice`.
34
+ * @example
35
+ * listWindow(347, 0, 20); // { start: 0, end: 20 }
36
+ * listWindow(347, 200, 20); // { start: 190, end: 210 } — cursor centrado
37
+ * listWindow(347, 346, 20); // { start: 327, end: 347 } — grudado no fim
38
+ */
39
+ export declare function listWindow(total: number, selected: number, height: number): ListWindow;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Janela visível de uma lista longa, seguindo o cursor.
3
+ *
4
+ * A home renderizava TODAS as issues de uma vez. Enquanto a lista era curta
5
+ * (as abertas de uma pessoa) isso passava; filtrando por um status com centenas
6
+ * de itens, a lista estoura a altura do terminal, o rodapé é empurrado para fora
7
+ * e o cursor — que começa no topo — some da tela: "cadê meu cursor".
8
+ *
9
+ * Esta é a contraparte de `./components/scroll-view.tsx` para listas
10
+ * SELECIONÁVEIS: lá o deslocamento é dirigido pelo teclado sobre conteúdo
11
+ * estático; aqui ele acompanha o índice selecionado, que é quem manda.
12
+ *
13
+ * Sem estado próprio de propósito: a janela é função do (total, selecionado,
14
+ * altura). Guardar um offset exigiria sincronizá-lo com a seleção, com a troca
15
+ * de filtro e com o redimensionamento do terminal — três fontes de divergência.
16
+ */
17
+ /**
18
+ * Calcula a fatia visível mantendo o item selecionado dentro dela.
19
+ *
20
+ * O cursor é mantido ao MEIO da janela sempre que possível; nas pontas a janela
21
+ * gruda no início ou no fim, para não sobrar espaço vazio.
22
+ *
23
+ * @param total - Quantidade de itens da lista.
24
+ * @param selected - Índice selecionado (fora da faixa é fixado nela).
25
+ * @param height - Linhas disponíveis; `<= 0` devolve uma janela vazia.
26
+ * @returns O intervalo `[start, end)` para `slice`.
27
+ * @example
28
+ * listWindow(347, 0, 20); // { start: 0, end: 20 }
29
+ * listWindow(347, 200, 20); // { start: 190, end: 210 } — cursor centrado
30
+ * listWindow(347, 346, 20); // { start: 327, end: 347 } — grudado no fim
31
+ */
32
+ export function listWindow(total, selected, height) {
33
+ if (height <= 0 || total <= 0)
34
+ return { start: 0, end: 0 };
35
+ if (total <= height)
36
+ return { start: 0, end: total };
37
+ const cursor = Math.min(Math.max(selected, 0), total - 1);
38
+ const half = Math.floor(height / 2);
39
+ const start = Math.min(Math.max(0, cursor - half), total - height);
40
+ return { start, end: start + height };
41
+ }
@@ -53,6 +53,7 @@ import { useExportBundle } from '../hooks/use-export-bundle.js';
53
53
  import { useListNavigation } from '../hooks/use-list-navigation.js';
54
54
  import { useJobRegistry } from '../job-registry.js';
55
55
  import { useTerminalWidth } from '../hooks/use-terminal-width.js';
56
+ import { isTyping } from '../hooks/use-typing-guard.js';
56
57
  import { useNavigation } from '../navigation.js';
57
58
  import { symbols } from '../symbols.js';
58
59
  import { useTheme } from '../theme.js';
@@ -154,8 +155,11 @@ export function ExportScreen() {
154
155
  return;
155
156
  }
156
157
  // "b" não interrompe uma exportação em andamento — evita voltar no meio
157
- // de uma gravação em disco.
158
- if (input === 'b' && stateStatusRef.current !== 'exporting') {
158
+ // de uma gravação em disco — nem dispara enquanto o campo "Destino" está
159
+ // capturando teclado: o Ink entrega a tecla aos dois handlers, então um
160
+ // caminho como `~/backup/` ou `bundle.json` fecharia a tela no meio da
161
+ // digitação (ver ../hooks/use-typing-guard.ts).
162
+ if (input === 'b' && !isTyping() && stateStatusRef.current !== 'exporting') {
159
163
  popRef.current();
160
164
  return;
161
165
  }
@@ -21,7 +21,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
21
21
  * retry.
22
22
  *
23
23
  * M2-07 (#30) acrescenta a busca/filtros inline: `/` abre um `TextInput`
24
- * QUANDO a home está ativa; `f` cicla o filtro com a busca FECHADA (badge no
24
+ * QUANDO a home está ativa; `f` abre o seletor de status com a busca FECHADA (badge no
25
25
  * cabeçalho); Esc fecha a busca sem refetch, interceptado via
26
26
  * `../hooks/use-escape-interceptor.ts` para não desempilhar a home.
27
27
  *
@@ -43,12 +43,15 @@ import { Spinner } from '../components/spinner.js';
43
43
  import { TextInput } from '../components/text-input.js';
44
44
  import { glyphs } from '../glyphs.js';
45
45
  import { useEscapeInterceptor } from '../hooks/use-escape-interceptor.js';
46
+ import { isTyping } from '../hooks/use-typing-guard.js';
47
+ import { listWindow } from '../list-window.js';
48
+ import { statusFilterLabel, useStatusOptions } from '../hooks/use-status-options.js';
46
49
  import { useIssueSearch } from '../hooks/use-issue-search.js';
47
50
  import { useListNavigation } from '../hooks/use-list-navigation.js';
48
51
  import { useMyIssues } from '../hooks/use-my-issues.js';
49
- import { useTerminalWidth } from '../hooks/use-terminal-width.js';
52
+ import { useTerminalHeight, useTerminalWidth } from '../hooks/use-terminal-width.js';
50
53
  import { useNavigation } from '../navigation.js';
51
- import { statusColor } from '../status-color.js';
54
+ import { statusColor, statusFilterColor } from '../status-color.js';
52
55
  import { symbols } from '../symbols.js';
53
56
  import { useTheme } from '../theme.js';
54
57
  import { truncate } from '../truncate.js';
@@ -61,20 +64,14 @@ const SCREEN_PADDING_X = 2;
61
64
  // literal `[]` inline em cada render seria recriado a cada chamada,
62
65
  // invalidando memoizações a jusante (`useListNavigation`) sem necessidade.
63
66
  const EMPTY_ISSUES = [];
64
- /** Rótulo do badge de cada filtro rápido de status (tecla `f` cicla, M2-07/#30). */
65
- const STATUS_FILTER_LABELS = {
66
- open: 'aberta',
67
- closed: 'fechada',
68
- all: 'todas',
69
- };
70
- /** Próximo filtro no ciclo `aberta fechada → todas → aberta` (tecla `f`). */
71
- function nextStatusFilter(current) {
72
- if (current === 'open')
73
- return 'closed';
74
- if (current === 'closed')
75
- return 'all';
76
- return 'open';
77
- }
67
+ /**
68
+ * Linhas ocupadas fora da lista NESTA tela (breadcrumb, cabeçalho, contador,
69
+ * rodapé, paddings). A moldura da aplicação não entra: o shell já entrega a
70
+ * altura sem ela (ver `TerminalHeightProvider` em `../app.tsx`).
71
+ */
72
+ const LIST_OVERHEAD_ROWS = 9;
73
+ /** Piso da janela da lista (terminais muito baixos). */
74
+ const LIST_MIN_HEIGHT = 5;
78
75
  /**
79
76
  * Espaço fixo ocupado pela linha FORA do subject (M2-16, #39): ponteiro (2),
80
77
  * `#id ` (id + `#`/espaço), 1 espaço final após o subject, `[status]`
@@ -89,6 +86,20 @@ function fixedRowOverhead(idLength, statusLength) {
89
86
  return POINTER_WIDTH + ID_PREFIX_WIDTH + SUBJECT_TRAILING_SPACE + STATUS_BRACKETS_WIDTH + SCREEN_PADDING_X;
90
87
  }
91
88
  /** Uma linha da lista: `#id` em `theme.muted`, subject truncado ao orçamento de largura, badge de status. */
89
+ /**
90
+ * Uma linha de RESULTADO DE BUSCA.
91
+ *
92
+ * Espelha o layout do {@link IssueRow} (id · assunto · status), mas a partir do
93
+ * item estruturado da busca — sem passar pelo Markdown do bundle, que traria as
94
+ * fences `<untrusted-content>` para a tela.
95
+ */
96
+ function SearchResultRow({ item, selected }) {
97
+ const theme = useTheme();
98
+ const terminalWidth = useTerminalWidth();
99
+ const overhead = fixedRowOverhead(String(item.id).length, item.status.length);
100
+ const subjectBudget = Math.max(terminalWidth - overhead, MIN_SUBJECT_WIDTH);
101
+ return (_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: selected ? `${symbols.pointerSmall} ` : ' ' }), _jsxs(Text, { color: theme.muted, children: ["#", item.id, " "] }), _jsx(Text, { ...(selected ? { color: theme.primary } : {}), children: truncate(item.subject ?? '(sem assunto)', subjectBudget) }), _jsxs(Text, { color: statusColor(theme, item.status), children: [" [", item.status, "]"] }), _jsxs(Text, { color: theme.muted, children: [" ", item.assignee] })] }));
102
+ }
92
103
  function IssueRow({ issue, selected }) {
93
104
  const theme = useTheme();
94
105
  const terminalWidth = useTerminalWidth();
@@ -100,28 +111,64 @@ function IssueRow({ issue, selected }) {
100
111
  export function HomeScreen() {
101
112
  const theme = useTheme();
102
113
  const { push } = useNavigation();
103
- const { state, retry } = useMyIssues();
104
114
  // #31: índice preservado entre remounts + registro de qual issue foi aberta
105
115
  // (ver o JSDoc do módulo e de `./home-selection.js`).
106
116
  const { selectedIndex: persistedIndex, setSelectedIndex: persistIndex, setSelectedIssueId } = useHomeSelection();
107
117
  // --- Busca/filtros inline (M2-07, #30) ---
108
118
  const [isSearching, setIsSearching] = useState(false);
109
119
  const [query, setQuery] = useState('');
110
- const [statusFilter, setStatusFilter] = useState('all');
111
- const search = useIssueSearch(query, statusFilter);
120
+ // Default ABERTAS, não "todas": a home é a lista de trabalho. Com `all` o
121
+ // Redmine devolve o histórico inteiro (centenas de fechadas), que estoura a
122
+ // tela e enterra o que importa — era o default implícito antes do filtro
123
+ // chegar à lista (a API omite fechadas quando `status_id` não é enviado).
124
+ const [statusFilter, setStatusFilter] = useState('open');
125
+ // Seletor de status (#30, revisto): `f` abre uma LISTA em vez de ciclar às
126
+ // cegas — a instância tem uma dezena de status e o usuário não tem como
127
+ // adivinhar qual vem a seguir num ciclo.
128
+ const [isPickingStatus, setIsPickingStatus] = useState(false);
129
+ const statusOptions = useStatusOptions();
130
+ const [statusIndex, setStatusIndex] = useState(0);
131
+ // Seleção dentro dos RESULTADOS da busca: achar o chamado e não conseguir
132
+ // abrir é o mesmo que não ter achado. As setas não são texto, então navegam
133
+ // os resultados enquanto o campo continua recebendo letras.
134
+ const [searchIndex, setSearchIndex] = useState(0);
135
+ const searchIndexRef = useRef(searchIndex);
136
+ searchIndexRef.current = searchIndex;
137
+ // O filtro só vai para a BUSCA quando ela está aberta: com ela fechada, quem
138
+ // aplica o status é a lista (abaixo), e passar o filtro aqui dispararia um
139
+ // request cujo resultado nunca é renderizado — dois GETs por `f` em vez de um.
140
+ const search = useIssueSearch(query, isSearching ? statusFilter : 'all');
141
+ // O filtro rápido (`f`) precisa valer para a LISTA visível — antes ele só
142
+ // alimentava a busca, cujos resultados só aparecem com a busca ABERTA, então
143
+ // trocar o status não mudava nada na tela.
144
+ const { state, retry } = useMyIssues({ statusFilter });
112
145
  // Handler ESTÁVEL: `search.clear` é a única dependência mutável (mas já é
113
146
  // estável por construção, ver `use-issue-search.ts`) — fecha a busca e
114
147
  // restaura os 3 estados neutros de uma vez (query/filtro/hook de busca).
115
148
  const closeSearch = useCallback(() => {
116
149
  setIsSearching(false);
117
150
  setQuery('');
118
- setStatusFilter('all');
151
+ // O filtro de status NÃO é resetado: desde que ele governa a LISTA (e não
152
+ // só a busca), zerá-lo aqui desfazia uma escolha que o usuário fez ANTES de
153
+ // abrir a busca — `f`, depois `/`, depois `Esc` devolvia [Todas].
119
154
  search.clear();
120
155
  }, [search.clear]);
121
156
  // Desvia o Esc GLOBAL (`../app.tsx`) enquanto a busca está aberta — sem
122
157
  // isso, Esc desempilharia a home inteira em vez de só fechar a busca.
158
+ // Resultado novo, cursor no topo: manter o índice de uma busca anterior faria
159
+ // o Enter abrir uma issue que não é a que está sob o cursor.
160
+ useEffect(() => {
161
+ setSearchIndex(0);
162
+ searchIndexRef.current = 0;
163
+ }, [query, statusFilter]);
123
164
  useEscapeInterceptor(isSearching, closeSearch);
165
+ const closeStatusPicker = useCallback(() => setIsPickingStatus(false), []);
166
+ useEscapeInterceptor(isPickingStatus, closeStatusPicker);
124
167
  const issues = state.status === 'loaded' ? state.issues : EMPTY_ISSUES;
168
+ // Altura da lista: o terminal menos a moldura, breadcrumb, cabeçalho, contador
169
+ // e rodapé. A lista rola DENTRO dessa janela em vez de empurrar o resto da
170
+ // tela para fora — e a mesma altura é o salto de uma página.
171
+ const listHeight = Math.max(LIST_MIN_HEIGHT, useTerminalHeight() - LIST_OVERHEAD_ROWS);
125
172
  // Handlers ESTÁVEIS (useCallback + refs, padrão do repo): identidade nova a
126
173
  // cada render des/re-subscreve o useInput e pode perder uma tecla rápida.
127
174
  const pushRef = useRef(push);
@@ -139,8 +186,13 @@ export function HomeScreen() {
139
186
  // última posição persistida (sobrevive ao unmount via home-selection).
140
187
  const { selectedIndex } = useListNavigation(issues.length, {
141
188
  onSelect: handleSelect,
142
- isActive: !isSearching,
189
+ // Também desligada com o SELETOR DE STATUS aberto: o Ink entrega a tecla a
190
+ // todos os handlers, então o Enter que aplica o filtro abria a issue
191
+ // selecionada por baixo, ao mesmo tempo.
192
+ isActive: !isSearching && !isPickingStatus,
143
193
  initialIndex: persistedIndex,
194
+ // Uma página = uma tela da janela visível (ver ../list-window.ts).
195
+ pageSize: listHeight,
144
196
  });
145
197
  // Espelha `selectedIndex` ao contexto — a cópia externa sobrevive ao
146
198
  // unmount desta tela (ver ./home-selection.js).
@@ -155,6 +207,16 @@ export function HomeScreen() {
155
207
  statusRef.current = state.status;
156
208
  const isSearchingRef = useRef(isSearching);
157
209
  isSearchingRef.current = isSearching;
210
+ const isPickingStatusRef = useRef(isPickingStatus);
211
+ isPickingStatusRef.current = isPickingStatus;
212
+ const optionsRef = useRef(statusOptions);
213
+ optionsRef.current = statusOptions;
214
+ const statusIndexRef = useRef(statusIndex);
215
+ statusIndexRef.current = statusIndex;
216
+ const filterRef = useRef(statusFilter);
217
+ filterRef.current = statusFilter;
218
+ const searchItemsRef = useRef([]);
219
+ searchItemsRef.current = search.state.status === 'loaded' ? search.state.items : [];
158
220
  const handleRetryInput = useCallback((input) => {
159
221
  // M2-07 (#30): "r" digitado como texto de busca não deve disparar retry.
160
222
  if (isSearchingRef.current)
@@ -168,33 +230,99 @@ export function HomeScreen() {
168
230
  }
169
231
  }, []);
170
232
  useInput(handleRetryInput);
171
- // "/" abre a busca; "f" cicla o filtro rápido de status (com a busca
172
- // aberta). Reason: dentro do campo de texto, "f" digitado NÃO vira
173
- // caractere da queryé a tecla de controle do filtro, trade-off
174
- // Com a busca ABERTA, toda letra pertence à query (buscar "workflow" exige
175
- // digitar "f") — o ciclo de filtro por "f" só vale com a busca fechada.
233
+ // "/" abre a busca; "f" abre o seletor de status. Ambos FORA de campo de
234
+ // texto: com a busca aberta toda letra pertence à query (buscar "workflow"
235
+ // exige digitar "f")ver ../hooks/use-typing-guard.ts.
176
236
  const handleSearchControlInput = useCallback((input) => {
177
- if (input === '/' && !isSearchingRef.current) {
237
+ if (isPickingStatusRef.current)
238
+ return;
239
+ if (input === '/' && !isTyping()) {
178
240
  setIsSearching(true);
179
241
  return;
180
242
  }
181
- if (input === 'f' && !isSearchingRef.current) {
182
- setStatusFilter((current) => nextStatusFilter(current));
243
+ if (input === 'f' && !isTyping()) {
244
+ // Abre já posicionado no filtro atual, para o usuário ver onde está.
245
+ const start = Math.max(0, optionsRef.current.findIndex((o) => o.value === filterRef.current));
246
+ statusIndexRef.current = start;
247
+ setStatusIndex(start);
248
+ setIsPickingStatus(true);
183
249
  }
184
250
  }, []);
185
251
  useInput(handleSearchControlInput);
252
+ // Navegação do seletor de status.
253
+ const handleStatusPickerInput = useCallback((input, key) => {
254
+ if (!isPickingStatusRef.current)
255
+ return;
256
+ const total = optionsRef.current.length;
257
+ // A ref avança JUNTO com o estado: sincronizá-la só no render faz o
258
+ // `Enter` logo após um `j`/`k` ler o índice ANTIGO (o React ainda não
259
+ // commitou) e aplicar o filtro errado — mesmo defeito que o TextInput
260
+ // tinha ao perder teclas digitadas rápido.
261
+ const move = (delta) => {
262
+ const next = (statusIndexRef.current + delta + total) % total;
263
+ statusIndexRef.current = next;
264
+ setStatusIndex(next);
265
+ };
266
+ if (key.upArrow || input === 'k') {
267
+ move(-1);
268
+ return;
269
+ }
270
+ if (key.downArrow || input === 'j') {
271
+ move(1);
272
+ return;
273
+ }
274
+ if (key.return) {
275
+ const picked = optionsRef.current[statusIndexRef.current];
276
+ if (picked !== undefined)
277
+ setStatusFilter(picked.value);
278
+ setIsPickingStatus(false);
279
+ }
280
+ }, []);
281
+ useInput(handleStatusPickerInput);
282
+ // Navegação dos RESULTADOS da busca (setas + Enter). O campo de texto ignora
283
+ // setas e Enter, então não há disputa: as letras seguem indo para a query.
284
+ const handleSearchResultsInput = useCallback((_input, key) => {
285
+ const items = searchItemsRef.current;
286
+ if (!isSearchingRef.current || items.length === 0)
287
+ return;
288
+ if (key.upArrow) {
289
+ setSearchIndex((i) => (i - 1 + items.length) % items.length);
290
+ return;
291
+ }
292
+ if (key.downArrow) {
293
+ setSearchIndex((i) => (i + 1) % items.length);
294
+ return;
295
+ }
296
+ if (key.return) {
297
+ const picked = items[searchIndexRef.current];
298
+ if (picked !== undefined) {
299
+ setSelectedIssueIdRef.current(picked.id);
300
+ pushRef.current('issue-detail');
301
+ }
302
+ }
303
+ }, []);
304
+ useInput(handleSearchResultsInput);
186
305
  // #34 (M2-11): "t" abre o painel de jobs da sessão (`./jobs.js`) — só fora
187
306
  // da busca (mesma guarda de "/"/"f" acima: dentro do campo, "t" é texto da
188
307
  // query, não um atalho).
189
308
  const handleJobsShortcut = useCallback((input) => {
190
- if (input === 't' && !isSearchingRef.current) {
309
+ if (input === 't' && !isTyping()) {
191
310
  pushRef.current('jobs');
192
311
  }
193
312
  }, []);
194
313
  useInput(handleJobsShortcut);
314
+ const listWindow_ = listWindow(issues.length, selectedIndex, listHeight);
195
315
  const searchState = search.state;
196
- return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "Minhas issues" }), _jsxs(Text, { color: theme.muted, children: [" [", STATUS_FILTER_LABELS[statusFilter], "]"] }), !isSearching ? (_jsx(Text, { color: theme.muted, children: ` / busca ${glyphs.middleDot} f filtro ${glyphs.middleDot} t jobs` })) : null] }), isSearching ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "Buscar: " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: `digite para buscar${glyphs.ellipsis}`, isActive: isSearching }), _jsxs(Text, { color: theme.muted, children: [" [", STATUS_FILTER_LABELS[statusFilter], "]"] })] }), searchState.status === 'idle' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "Digite para buscar ou pressione f para ciclar o filtro de status." }) })) : null, searchState.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Buscando..."] }) })) : null, searchState.status === 'loaded' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [searchState.degraded ? (_jsxs(Text, { color: theme.warning, children: [symbols.warning, " ", searchState.warnings.join(' ')] })) : null, _jsx(Text, { children: searchState.content })] })) : null, searchState.status === 'error-network' || searchState.status === 'error-forbidden' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", searchState.message] }) })) : null, searchState.status === 'auth-aborted' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: searchState.message }) })) : null] })) : (_jsxs(_Fragment, { children: [state.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Carregando issues..."] }) })) : null, state.status === 'empty' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "nenhuma issue atribu\u00EDda" }) })) : null, state.status === 'error-network' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.danger, children: [symbols.cross, " Falha ao carregar suas issues: ", state.message] }), _jsxs(Text, { color: theme.muted, children: ["Pressione", ' ', _jsx(Text, { color: theme.accent, children: "r" }), ' ', "para tentar de novo."] })] })) : null, state.status === 'error-forbidden' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }), _jsxs(Text, { color: theme.muted, children: ["Pressione", ' ', _jsx(Text, { color: theme.accent, children: "r" }), ' ', "para tentar de novo."] })] })) : null, state.status === 'auth-aborted' ? (
316
+ return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "Minhas issues" }), _jsxs(Text, { color: statusFilterColor(theme, statusFilter, statusFilterLabel(statusOptions, statusFilter)), children: [' ', "[", statusFilterLabel(statusOptions, statusFilter), "]"] }), state.status === 'loaded' ? (_jsx(Text, { color: theme.muted, children: ` ${glyphs.middleDot} ${issues.length} ${issues.length === 1 ? 'issue' : 'issues'}` })) : null, !isSearching && !isPickingStatus ? (_jsx(Text, { color: theme.muted, children: ` / busca ${glyphs.middleDot} f filtro ${glyphs.middleDot} t jobs` })) : null] }), isPickingStatus ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.primary, children: "Filtrar por status" }), statusOptions.map((option, i) => {
317
+ const selected = i === statusIndex;
318
+ const current = option.value === statusFilter;
319
+ // Divisor entre os AGREGADOS do Redmine e os status da instância:
320
+ // sem ele, "Fechadas" (agregado) e "Fechada" (status) ficam coladas
321
+ // e parecem duplicata.
322
+ const firstConcrete = typeof option.value === 'number' && typeof statusOptions[i - 1]?.value !== 'number';
323
+ return (_jsxs(Box, { flexDirection: "column", children: [firstConcrete ? (_jsx(Text, { color: theme.border, children: ` ${'─'.repeat(18)}` })) : null, _jsxs(Box, { children: [_jsxs(Text, { color: selected ? theme.primary : theme.muted, children: [selected ? symbols.pointer : ' ', ' '] }), _jsx(Text, { color: statusFilterColor(theme, option.value, option.label), children: option.label }), current ? _jsxs(Text, { color: theme.muted, children: [" ", symbols.tick, " atual"] }) : null] })] }, String(option.value)));
324
+ }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.muted, children: [_jsx(Text, { color: theme.accent, children: `${glyphs.arrowUp}/${glyphs.arrowDown}` }), " escolhe", ' ', glyphs.middleDot, " ", _jsx(Text, { color: theme.accent, children: "Enter" }), " aplica ", glyphs.middleDot, ' ', _jsx(Text, { color: theme.accent, children: "Esc" }), " cancela"] }) })] })) : null, isSearching ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "Buscar: " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: `digite para buscar${glyphs.ellipsis}`, isActive: isSearching }), _jsxs(Text, { color: statusFilterColor(theme, statusFilter, statusFilterLabel(statusOptions, statusFilter)), children: [' ', "[", statusFilterLabel(statusOptions, statusFilter), "]"] })] }), searchState.status === 'idle' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "Digite para buscar. Esc fecha a busca (o filtro de status \u00E9 o f com a busca fechada)." }) })) : null, searchState.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Buscando..."] }) })) : null, searchState.status === 'loaded' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [searchState.degraded ? (_jsxs(Text, { color: theme.warning, children: [symbols.warning, " ", searchState.warnings.join(' ')] })) : null, searchState.items.length === 0 ? (_jsx(Text, { color: theme.muted, children: "nenhuma issue encontrada" })) : (searchState.items.map((item, index) => (_jsx(SearchResultRow, { item: item, selected: index === searchIndex }, item.id))))] })) : null, searchState.status === 'error-network' || searchState.status === 'error-forbidden' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", searchState.message] }) })) : null, searchState.status === 'auth-aborted' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: searchState.message }) })) : null] })) : (_jsxs(_Fragment, { children: [state.status === 'loading' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, {}), " Carregando issues..."] }) })) : null, state.status === 'empty' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "nenhuma issue atribu\u00EDda" }) })) : null, state.status === 'error-network' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.danger, children: [symbols.cross, " Falha ao carregar suas issues: ", state.message] }), _jsxs(Text, { color: theme.muted, children: ["Pressione", ' ', _jsx(Text, { color: theme.accent, children: "r" }), ' ', "para tentar de novo."] })] })) : null, state.status === 'error-forbidden' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.danger, children: [symbols.cross, " ", state.message] }), _jsxs(Text, { color: theme.muted, children: ["Pressione", ' ', _jsx(Text, { color: theme.accent, children: "r" }), ' ', "para tentar de novo."] })] })) : null, state.status === 'auth-aborted' ? (
197
325
  // Fix do review #120: estado NEUTRO (abandono consciente do re-login,
198
326
  // Esc) — `theme.muted`, não `theme.danger` (não é uma falha).
199
- _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { color: theme.muted, children: state.message }) })) : null, state.status === 'loaded' ? (_jsx(Box, { marginTop: 1, flexDirection: "column", children: state.issues.map((issue, index) => (_jsx(IssueRow, { issue: issue, selected: index === selectedIndex }, issue.id))) })) : null] })), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: isSearching ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.accent, children: "Esc" }), ' ', "fecha a busca,", ' ', _jsx(Text, { color: theme.accent, children: "f" }), ' ', "cicla o filtro."] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.accent, children: `${glyphs.arrowUp}/${glyphs.arrowDown}` }), ' ', "navega,", ' ', _jsx(Text, { color: theme.accent, children: "Enter" }), ' ', "abre a issue,", ' ', _jsx(Text, { color: theme.accent, children: "/" }), ' ', "busca,", ' ', _jsx(Text, { color: theme.accent, children: "t" }), ' ', "jobs,", ' ', _jsx(Text, { color: theme.accent, children: "Esc" }), ' ', "volta."] })) }) })] }));
327
+ _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { color: theme.muted, children: state.message }) })) : null, state.status === 'loaded' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [state.issues.slice(listWindow_.start, listWindow_.end).map((issue, index) => (_jsx(IssueRow, { issue: issue, selected: listWindow_.start + index === selectedIndex }, issue.id))), state.issues.length > listWindow_.end - listWindow_.start ? (_jsxs(Text, { color: theme.muted, children: [` ${listWindow_.start + 1}-${listWindow_.end} de ${state.issues.length}`, listWindow_.start > 0 ? ` ${glyphs.arrowUp}` : '', listWindow_.end < state.issues.length ? ` ${glyphs.arrowDown}` : ''] })) : null] })) : null] })), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: isSearching ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.accent, children: "Esc" }), ' ', "fecha a busca,", ' ', _jsx(Text, { color: theme.accent, children: `${glyphs.arrowUp}/${glyphs.arrowDown}` }), " navega os resultados,", ' ', _jsx(Text, { color: theme.accent, children: "Enter" }), " abre. O filtro \u00E9 o", ' ', _jsx(Text, { color: theme.accent, children: "f" }), " com a busca fechada."] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.accent, children: `${glyphs.arrowUp}/${glyphs.arrowDown}` }), ' ', "navega,", ' ', _jsx(Text, { color: theme.accent, children: `${glyphs.arrowLeft}/${glyphs.arrowRight}` }), ' ', "p\u00E1gina,", ' ', _jsx(Text, { color: theme.accent, children: "Enter" }), ' ', "abre a issue,", ' ', _jsx(Text, { color: theme.accent, children: "/" }), ' ', "busca,", ' ', _jsx(Text, { color: theme.accent, children: "t" }), ' ', "jobs,", ' ', _jsx(Text, { color: theme.accent, children: "Esc" }), ' ', "volta."] })) }) })] }));
200
328
  }