redmine-context 1.1.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.
- package/dist/bundle/journal-detail.js +26 -6
- package/dist/fetch-issue-search.d.ts +10 -0
- package/dist/fetch-issue-search.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/surfaces/tui/app.js +9 -3
- package/dist/surfaces/tui/components/text-input.js +23 -4
- package/dist/surfaces/tui/glyphs.d.ts +4 -0
- package/dist/surfaces/tui/glyphs.js +4 -0
- package/dist/surfaces/tui/hooks/use-issue-search.d.ts +25 -2
- package/dist/surfaces/tui/hooks/use-issue-search.js +14 -2
- package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +8 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.js +12 -1
- package/dist/surfaces/tui/hooks/use-my-issues.d.ts +9 -0
- package/dist/surfaces/tui/hooks/use-my-issues.js +6 -2
- package/dist/surfaces/tui/hooks/use-status-options.d.ts +38 -0
- package/dist/surfaces/tui/hooks/use-status-options.js +86 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +16 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.js +15 -0
- package/dist/surfaces/tui/hooks/use-typing-guard.d.ts +19 -0
- package/dist/surfaces/tui/hooks/use-typing-guard.js +58 -0
- package/dist/surfaces/tui/list-window.d.ts +39 -0
- package/dist/surfaces/tui/list-window.js +41 -0
- package/dist/surfaces/tui/screens/export.js +6 -2
- package/dist/surfaces/tui/screens/home.js +161 -33
- package/dist/surfaces/tui/screens/issue-detail.js +32 -14
- package/dist/surfaces/tui/status-color.d.ts +21 -0
- package/dist/surfaces/tui/status-color.js +33 -0
- package/package.json +1 -1
|
@@ -113,6 +113,23 @@ const RELATION_TYPES = new Set([
|
|
|
113
113
|
function idToken(raw) {
|
|
114
114
|
return /^\d+$/.test(raw) ? raw : undefined;
|
|
115
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Achata um valor de alteração em UMA linha.
|
|
118
|
+
*
|
|
119
|
+
* Editar a descrição de uma issue guarda o texto INTEIRO — com quebras de linha
|
|
120
|
+
* — nos dois lados do detail. Um resumo de alteração é, por definição, de uma
|
|
121
|
+
* linha: no bundle o valor multi-linha quebra o formato `- campo: antes → depois`
|
|
122
|
+
* (e escapa visualmente da fence); na TUI vira várias linhas de tela, furando a
|
|
123
|
+
* conta do viewport, que assume um item por linha.
|
|
124
|
+
*
|
|
125
|
+
* O conteúdo completo continua disponível na seção Descrição do bundle.
|
|
126
|
+
*
|
|
127
|
+
* @param raw - Valor bruto do detail.
|
|
128
|
+
* @returns O mesmo texto com quebras e espaços repetidos colapsados.
|
|
129
|
+
*/
|
|
130
|
+
function flatten(raw) {
|
|
131
|
+
return raw.replace(/\s*\r?\n\s*/g, ' ').trim();
|
|
132
|
+
}
|
|
116
133
|
/**
|
|
117
134
|
* Ref ATUAL da issue correspondente a um atributo, quando o contrato a carrega.
|
|
118
135
|
*
|
|
@@ -206,18 +223,21 @@ export function journalDetailLabel(detail, issue) {
|
|
|
206
223
|
export function journalDetailValue(detail, raw, issue, lookups) {
|
|
207
224
|
if (raw === null || raw === undefined || raw === '')
|
|
208
225
|
return undefined;
|
|
209
|
-
const
|
|
226
|
+
const flat = flatten(raw);
|
|
227
|
+
if (flat === '')
|
|
228
|
+
return undefined;
|
|
229
|
+
const id = idToken(flat);
|
|
210
230
|
// Um detail `relation` registra a issue do outro lado da relação.
|
|
211
231
|
if (detail.property === 'relation') {
|
|
212
|
-
return id === undefined ? { text:
|
|
232
|
+
return id === undefined ? { text: flat, trusted: false } : { text: `issue #${id}`, trusted: true };
|
|
213
233
|
}
|
|
214
234
|
if (detail.property !== 'attr')
|
|
215
|
-
return { text:
|
|
235
|
+
return { text: flat, trusted: false };
|
|
216
236
|
if (detail.name === 'done_ratio') {
|
|
217
|
-
return id === undefined ? { text:
|
|
237
|
+
return id === undefined ? { text: flat, trusted: false } : { text: `${id}%`, trusted: true };
|
|
218
238
|
}
|
|
219
239
|
if (ISSUE_REF_ATTRS.has(detail.name)) {
|
|
220
|
-
return id === undefined ? { text:
|
|
240
|
+
return id === undefined ? { text: flat, trusted: false } : { text: `issue #${id}`, trusted: true };
|
|
221
241
|
}
|
|
222
242
|
// Dicionário da instância primeiro: resolve QUALQUER id, inclusive o valor
|
|
223
243
|
// histórico que não corresponde mais ao estado atual.
|
|
@@ -236,5 +256,5 @@ export function journalDetailValue(detail, raw, issue, lookups) {
|
|
|
236
256
|
if (id !== undefined && ATTR_LABELS[detail.name] !== undefined && detail.name.endsWith('_id')) {
|
|
237
257
|
return { text: `#${id}`, trusted: true };
|
|
238
258
|
}
|
|
239
|
-
return { text:
|
|
259
|
+
return { text: flat, trusted: false };
|
|
240
260
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* Mantém a fronteira do ADR-005: encapsula os módulos internos (`client`,
|
|
17
17
|
* `bundle`) para que a superfície permaneça fina e sem acesso a URL/host.
|
|
18
18
|
*/
|
|
19
|
+
import { type SearchListItem } from './bundle/index.js';
|
|
19
20
|
/** Limite default de resultados quando a superfície não informa `limit`. */
|
|
20
21
|
export declare const SEARCH_DEFAULT_LIMIT = 25;
|
|
21
22
|
/**
|
|
@@ -51,6 +52,15 @@ export interface FetchIssueSearchOptions {
|
|
|
51
52
|
export interface IssueSearchResult {
|
|
52
53
|
/** Lista compacta em Markdown pronta para o CallToolResult. */
|
|
53
54
|
content: string;
|
|
55
|
+
/**
|
|
56
|
+
* Os mesmos itens em forma ESTRUTURADA.
|
|
57
|
+
*
|
|
58
|
+
* O `content` é Markdown com fences `<untrusted-content>` — marcação
|
|
59
|
+
* anti prompt-injection destinada ao LLM. Uma interface que o exiba mostra
|
|
60
|
+
* essas tags ao usuário, que é ruído: a TUI renderiza a partir daqui e aplica
|
|
61
|
+
* sua própria apresentação.
|
|
62
|
+
*/
|
|
63
|
+
items: readonly SearchListItem[];
|
|
54
64
|
/** Número de itens retornados. */
|
|
55
65
|
count: number;
|
|
56
66
|
/** Avisos de degradação (ex.: `/search` indisponível). Vazio no caminho feliz. */
|
|
@@ -116,5 +116,5 @@ export async function fetchIssueSearch(options) {
|
|
|
116
116
|
}
|
|
117
117
|
const items = payloads.slice(0, limit).map(toSearchListItem);
|
|
118
118
|
const content = buildSearchListMarkdown(items, { query, warnings });
|
|
119
|
-
return { content, count: items.length, warnings, degraded };
|
|
119
|
+
return { content, items, count: items.length, warnings, degraded };
|
|
120
120
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { extractIssueAttachments, type ExtractIssueAttachmentsOptions, } from '.
|
|
|
7
7
|
export { fetchAttachmentText, fetchAttachmentTextCacheFirst, AttachmentNotFoundError, type FetchAttachmentTextOptions, type FetchAttachmentTextCacheFirstOptions, type AttachmentTextResult, } from './fetch-attachment-text.js';
|
|
8
8
|
export { extractIssueAttachmentsCacheFirst, makeQueueBackgroundExtractor, processingResult, type BackgroundExtractionTarget, type BackgroundExtractor, type BackgroundCompute, type CacheFirstExtractionOptions, type QueueBackgroundOptions, } from './cache-first.js';
|
|
9
9
|
export { fetchIssueSearch, SEARCH_DEFAULT_LIMIT, type FetchIssueSearchOptions, type IssueSearchFilters, type IssueSearchResult, } from './fetch-issue-search.js';
|
|
10
|
+
export type { SearchListItem } from './bundle/index.js';
|
|
10
11
|
export { searchIssues, type SearchIssuesOptions, type SearchIssuesPage } from './client/index.js';
|
|
11
12
|
export { fetchLastIssues, LAST_DEFAULT_ORDER, LAST_DEFAULT_COUNT, LAST_MAX_COUNT, type LastIssuesOrder, type FetchLastIssuesOptions, type LastIssuesResult, } from './fetch-last-issues.js';
|
|
12
13
|
export { createHttpClient, type HttpClient, type HttpClientOptions, type QueryParams, } from './client/index.js';
|
package/dist/surfaces/tui/app.js
CHANGED
|
@@ -35,12 +35,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
35
35
|
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
36
36
|
import { Breadcrumb } from './components/breadcrumb.js';
|
|
37
37
|
import { isUnicodeSupported } from './glyphs.js';
|
|
38
|
-
import { TerminalSizeProvider, useTerminalHeight } from './hooks/use-terminal-width.js';
|
|
38
|
+
import { TerminalHeightProvider, TerminalSizeProvider, useTerminalHeight, } from './hooks/use-terminal-width.js';
|
|
39
39
|
import { applyTerminalColors } from './terminal-colors.js';
|
|
40
40
|
import { ReAuthAbortedError } from './hooks/use-auth-guard.js';
|
|
41
41
|
import { consumeEscapeInterceptor } from './hooks/use-escape-interceptor.js';
|
|
42
42
|
import { useExitGuard } from './hooks/use-exit-guard.js';
|
|
43
43
|
import { useOnboardingCallbacks } from './hooks/use-onboarding-callbacks.js';
|
|
44
|
+
import { isTyping } from './hooks/use-typing-guard.js';
|
|
44
45
|
import { JobRegistryProvider } from './job-registry.js';
|
|
45
46
|
import { NavigationProvider, useNavigation, useNavigationStack } from './navigation.js';
|
|
46
47
|
import { HomeSelectionProvider } from './screens/home-selection.js';
|
|
@@ -75,6 +76,8 @@ export function borderStyleFor(unicode) {
|
|
|
75
76
|
}
|
|
76
77
|
/** Estilo resolvido para o ambiente atual — decidido uma vez, no import. */
|
|
77
78
|
const BORDER_STYLE = borderStyleFor(isUnicodeSupported());
|
|
79
|
+
/** Linhas consumidas pela moldura (topo + base). */
|
|
80
|
+
const BORDER_ROWS = 2;
|
|
78
81
|
/**
|
|
79
82
|
* Decide a ação de `Esc`: abandono do re-auth (fix do review #119) quando a
|
|
80
83
|
* tela atual é do fluxo de onboarding (`onboarding-*`) E há um `reAuth` em
|
|
@@ -165,7 +168,10 @@ function AppShell() {
|
|
|
165
168
|
const abortReAuthRef = useRef(abortReAuth);
|
|
166
169
|
abortReAuthRef.current = abortReAuth;
|
|
167
170
|
const handleGlobalInput = useCallback((input, key) => {
|
|
168
|
-
|
|
171
|
+
// `q` só sai FORA de campo de texto: o Ink entrega a tecla a todos os
|
|
172
|
+
// handlers, então sem esta guarda digitar uma URL com "q" (ou uma senha)
|
|
173
|
+
// fecharia a TUI no meio do onboarding (ver ./hooks/use-typing-guard.ts).
|
|
174
|
+
if (input === 'q' && !isTyping()) {
|
|
169
175
|
exitRef.current();
|
|
170
176
|
return;
|
|
171
177
|
}
|
|
@@ -207,5 +213,5 @@ function AppShell() {
|
|
|
207
213
|
// não nas telas, então nenhuma tela precisa saber que existe uma. As duas
|
|
208
214
|
// linhas da borda saem do minHeight para o conteúdo não estourar a altura do
|
|
209
215
|
// terminal (o que empurraria o topo para fora no modo full-screen).
|
|
210
|
-
_jsxs(Box, { flexDirection: "column", minHeight: Math.max(1, rows -
|
|
216
|
+
_jsxs(Box, { flexDirection: "column", minHeight: Math.max(1, rows - BORDER_ROWS), borderStyle: BORDER_STYLE, borderColor: theme.border, children: [_jsx(Breadcrumb, { stack: stack }), armed ? (_jsx(Box, { paddingX: 1, marginBottom: 1, children: _jsxs(Text, { color: theme.warning, children: [symbols.warning, " Pressione Ctrl+C de novo para sair."] }) })) : null, _jsx(Box, { flexGrow: 1, flexDirection: "column", children: _jsx(TerminalHeightProvider, { height: Math.max(1, rows - BORDER_ROWS), children: _jsx(Screen, {}) }) })] }));
|
|
211
217
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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,6 +27,10 @@ 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`). */
|
|
@@ -58,6 +58,8 @@ export const UNICODE_GLYPHS = {
|
|
|
58
58
|
emptyPlaceholder: '—',
|
|
59
59
|
arrowUp: '↑',
|
|
60
60
|
arrowDown: '↓',
|
|
61
|
+
arrowLeft: '←',
|
|
62
|
+
arrowRight: '→',
|
|
61
63
|
maskBullet: '•',
|
|
62
64
|
spinnerFrames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
|
63
65
|
gaugeFull: '█',
|
|
@@ -70,6 +72,8 @@ export const ASCII_GLYPHS = {
|
|
|
70
72
|
emptyPlaceholder: '-',
|
|
71
73
|
arrowUp: '^',
|
|
72
74
|
arrowDown: 'v',
|
|
75
|
+
arrowLeft: '<',
|
|
76
|
+
arrowRight: '>',
|
|
73
77
|
maskBullet: '*',
|
|
74
78
|
spinnerFrames: ['|', '/', '-', '\\'],
|
|
75
79
|
gaugeFull: '#',
|
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
38
|
-
|
|
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, {
|
|
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;
|
|
@@ -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
|
-
|
|
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`
|
|
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
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
172
|
-
// aberta
|
|
173
|
-
//
|
|
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 só 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 (
|
|
237
|
+
if (isPickingStatusRef.current)
|
|
238
|
+
return;
|
|
239
|
+
if (input === '/' && !isTyping()) {
|
|
178
240
|
setIsSearching(true);
|
|
179
241
|
return;
|
|
180
242
|
}
|
|
181
|
-
if (input === 'f' && !
|
|
182
|
-
|
|
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' && !
|
|
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
|
|
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' ? (
|
|
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
|
}
|
|
@@ -47,23 +47,35 @@ import { useNavigation } from '../navigation.js';
|
|
|
47
47
|
import { statusColor } from '../status-color.js';
|
|
48
48
|
import { symbols } from '../symbols.js';
|
|
49
49
|
import { useTheme } from '../theme.js';
|
|
50
|
+
import { truncate } from '../truncate.js';
|
|
50
51
|
import { wrapText } from '../wrap.js';
|
|
51
52
|
import { useHomeSelection } from './home-selection.js';
|
|
52
53
|
import { useLoadedIssue } from './loaded-issue-context.js';
|
|
53
54
|
/**
|
|
54
|
-
* Overhead de linhas FORA da viewport de descrição
|
|
55
|
-
* rodapé
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
55
|
+
* Overhead de linhas FORA da viewport de descrição: breadcrumb, título, meta,
|
|
56
|
+
* rodapé e paddings DESTA tela.
|
|
57
|
+
*
|
|
58
|
+
* A moldura da aplicação não entra na conta: o shell já entrega a altura sem
|
|
59
|
+
* ela (`TerminalHeightProvider` em `../app.tsx`). Contar a menos faz o viewport
|
|
60
|
+
* pedir mais linhas do que cabem — o conteúdo transborda e o Ink, que redesenha
|
|
61
|
+
* por diff, sobrescreve linhas já impressas.
|
|
59
62
|
*/
|
|
60
63
|
const CONTENT_OVERHEAD_ROWS = 14;
|
|
61
64
|
/** Piso da viewport (terminais muito baixos). */
|
|
62
65
|
const CONTENT_MIN_HEIGHT = 6;
|
|
63
|
-
/**
|
|
64
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Colunas consumidas fora do texto: moldura da aplicação (2), padding da tela
|
|
68
|
+
* (2) e uma folga de 2.
|
|
69
|
+
*
|
|
70
|
+
* A folga existe porque errar para MENOS é pior que perder duas colunas: uma
|
|
71
|
+
* linha larga demais seria truncada com `…` bem no fim (ou, sem o truncate,
|
|
72
|
+
* quebrada pelo Ink, furando a conta do viewport).
|
|
73
|
+
*/
|
|
74
|
+
const CONTENT_WIDTH_OVERHEAD = 6;
|
|
65
75
|
/** Piso da largura de texto (terminais muito estreitos). */
|
|
66
76
|
const CONTENT_MIN_WIDTH = 20;
|
|
77
|
+
/** Corte de cada lado de uma alteração de journal (ver `buildContentRows`). */
|
|
78
|
+
const DETAIL_VALUE_WIDTH = 40;
|
|
67
79
|
/** Placeholder discreto para campos ausentes (assignee, autor/data de journal, old/new value). ASCII no Windows legado (#84). */
|
|
68
80
|
const EMPTY_PLACEHOLDER = glyphs.emptyPlaceholder;
|
|
69
81
|
/** Metadados fixos no topo: id/subject, status/prioridade, autor/responsável, datas. */
|
|
@@ -73,7 +85,7 @@ function IssueMeta({ issue, theme }) {
|
|
|
73
85
|
/** Constrói a linha de um único anexo: nome (texto derivado, puro) · tamanho humanizado · content_type · badge de status. */
|
|
74
86
|
function buildAttachmentRow(attachment, theme) {
|
|
75
87
|
const status = deriveAttachmentExtractionStatus(attachment);
|
|
76
|
-
return (_jsxs(Text, { children: [attachment.filename, ' ', _jsxs(Text, { color: theme.muted, children: ["(", humanizeFileSize(attachment.filesize), " ", glyphs.middleDot, " ", attachment.content_type ?? EMPTY_PLACEHOLDER, ")"] }), ' ', _jsxs(Text, { color: attachmentStatusColor(theme, status), children: ["[", attachmentStatusLabel(status), "]"] })] }, `attachment-${attachment.id}`));
|
|
88
|
+
return (_jsxs(Text, { wrap: "truncate", children: [attachment.filename, ' ', _jsxs(Text, { color: theme.muted, children: ["(", humanizeFileSize(attachment.filesize), " ", glyphs.middleDot, " ", attachment.content_type ?? EMPTY_PLACEHOLDER, ")"] }), ' ', _jsxs(Text, { color: attachmentStatusColor(theme, status), children: ["[", attachmentStatusLabel(status), "]"] })] }, `attachment-${attachment.id}`));
|
|
77
89
|
}
|
|
78
90
|
/** Constrói as linhas da seção de anexos (após os journals): cabeçalho + 1 linha por anexo, ou "(nenhum)". */
|
|
79
91
|
function buildAttachmentRows(issue, theme) {
|
|
@@ -102,7 +114,7 @@ function buildContentRows(issue, theme, width, lookups) {
|
|
|
102
114
|
// linhas de tela, e um parágrafo longo (o caso comum de um chamado) faria o
|
|
103
115
|
// Ink quebrá-lo sozinho em várias, estourando o viewport.
|
|
104
116
|
wrapText(issue.description, width).forEach((line, index) => {
|
|
105
|
-
rows.push(_jsx(Text, { children: line.length > 0 ? line : ' ' }, `desc-${index}`));
|
|
117
|
+
rows.push(_jsx(Text, { wrap: "truncate", children: line.length > 0 ? line : ' ' }, `desc-${index}`));
|
|
106
118
|
});
|
|
107
119
|
}
|
|
108
120
|
rows.push(_jsx(Text, { children: " " }, "desc-spacer"));
|
|
@@ -113,10 +125,10 @@ function buildContentRows(issue, theme, width, lookups) {
|
|
|
113
125
|
else {
|
|
114
126
|
// Cronológico: preserva a ordem já entregue por `getIssue`/`normalizeIssue`.
|
|
115
127
|
issue.journals.forEach((journal) => {
|
|
116
|
-
rows.push(_jsxs(Text, { color: theme.muted, children: [journal.user?.name ?? EMPTY_PLACEHOLDER, " ", glyphs.middleDot, " ", journal.created_on] }, `journal-${journal.id}-head`));
|
|
128
|
+
rows.push(_jsxs(Text, { color: theme.muted, wrap: "truncate", children: [journal.user?.name ?? EMPTY_PLACEHOLDER, " ", glyphs.middleDot, " ", journal.created_on] }, `journal-${journal.id}-head`));
|
|
117
129
|
if (journal.notes !== undefined && journal.notes !== '') {
|
|
118
130
|
wrapText(journal.notes, width).forEach((line, index) => {
|
|
119
|
-
rows.push(_jsx(Text, { children: line }, `journal-${journal.id}-note-${index}`));
|
|
131
|
+
rows.push(_jsx(Text, { wrap: "truncate", children: line }, `journal-${journal.id}-note-${index}`));
|
|
120
132
|
});
|
|
121
133
|
}
|
|
122
134
|
// Details resumidos: `campo: antigo → novo`, sempre em muted. Rótulos e
|
|
@@ -127,9 +139,15 @@ function buildContentRows(issue, theme, width, lookups) {
|
|
|
127
139
|
// interface, não prompt.
|
|
128
140
|
journal.details.forEach((detail, index) => {
|
|
129
141
|
const label = journalDetailLabel(detail, issue).text;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
142
|
+
// Valores de campo TEXTO (descrição/assunto) trazem o conteúdo inteiro:
|
|
143
|
+
// editar a descrição guarda os dois textos completos na alteração. No
|
|
144
|
+
// histórico interessa O QUE mudou, não o texto todo — o valor completo
|
|
145
|
+
// está no bundle, para o LLM. Sem o corte, uma linha de centenas de
|
|
146
|
+
// caracteres fura a conta do viewport (1 item = 1 linha de tela).
|
|
147
|
+
const short = (part) => truncate(part, DETAIL_VALUE_WIDTH);
|
|
148
|
+
const from = short(journalDetailValue(detail, detail.old_value, issue, lookups)?.text ?? EMPTY_PLACEHOLDER);
|
|
149
|
+
const to = short(journalDetailValue(detail, detail.new_value, issue, lookups)?.text ?? EMPTY_PLACEHOLDER);
|
|
150
|
+
rows.push(_jsxs(Text, { color: theme.muted, wrap: "truncate", children: [' ', label, ": ", from, " ", symbols.arrowRight, " ", to] }, `journal-${journal.id}-detail-${index}`));
|
|
133
151
|
});
|
|
134
152
|
rows.push(_jsx(Text, { children: " " }, `journal-${journal.id}-spacer`));
|
|
135
153
|
});
|
|
@@ -19,3 +19,24 @@ import type { Theme } from './theme.js';
|
|
|
19
19
|
* statusColor(theme, 'Fechado') // theme.success
|
|
20
20
|
*/
|
|
21
21
|
export declare function statusColor(theme: Theme, statusName: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Cor do badge do FILTRO rápido de status (`f` na home).
|
|
24
|
+
*
|
|
25
|
+
* Coerente com {@link statusColor}, que já pinta "fechado" de `success` e usa
|
|
26
|
+
* `primary` para o estado ativo — assim o badge do filtro e o badge de cada
|
|
27
|
+
* issue na lista não contam histórias diferentes sobre a mesma palavra.
|
|
28
|
+
*
|
|
29
|
+
* `all` fica em `muted` de propósito: é a ausência de filtro, e um badge
|
|
30
|
+
* chamativo aí competiria com o conteúdo. Qualquer estado FILTRADO puxa cor,
|
|
31
|
+
* que é o sinal de "tem filtro ligado".
|
|
32
|
+
*
|
|
33
|
+
* Contraste: todos são tokens do tema, calibrados por paleta (as claras usam
|
|
34
|
+
* cores saturadas, as escuras cores claras) — ver `./palettes.ts`.
|
|
35
|
+
*
|
|
36
|
+
* @param theme - Tema ativo.
|
|
37
|
+
* @param filter - Filtro corrente (agregado ou id de status da instância).
|
|
38
|
+
* @param label - Nome do status, quando o filtro é um id — permite casar a cor
|
|
39
|
+
* com a que a lista usa para aquele mesmo status.
|
|
40
|
+
* @returns O token de cor do tema para o badge.
|
|
41
|
+
*/
|
|
42
|
+
export declare function statusFilterColor(theme: Theme, filter: 'open' | 'closed' | 'all' | number, label?: string): string;
|
|
@@ -21,3 +21,36 @@ export function statusColor(theme, statusName) {
|
|
|
21
21
|
}
|
|
22
22
|
return theme.primary;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Cor do badge do FILTRO rápido de status (`f` na home).
|
|
26
|
+
*
|
|
27
|
+
* Coerente com {@link statusColor}, que já pinta "fechado" de `success` e usa
|
|
28
|
+
* `primary` para o estado ativo — assim o badge do filtro e o badge de cada
|
|
29
|
+
* issue na lista não contam histórias diferentes sobre a mesma palavra.
|
|
30
|
+
*
|
|
31
|
+
* `all` fica em `muted` de propósito: é a ausência de filtro, e um badge
|
|
32
|
+
* chamativo aí competiria com o conteúdo. Qualquer estado FILTRADO puxa cor,
|
|
33
|
+
* que é o sinal de "tem filtro ligado".
|
|
34
|
+
*
|
|
35
|
+
* Contraste: todos são tokens do tema, calibrados por paleta (as claras usam
|
|
36
|
+
* cores saturadas, as escuras cores claras) — ver `./palettes.ts`.
|
|
37
|
+
*
|
|
38
|
+
* @param theme - Tema ativo.
|
|
39
|
+
* @param filter - Filtro corrente (agregado ou id de status da instância).
|
|
40
|
+
* @param label - Nome do status, quando o filtro é um id — permite casar a cor
|
|
41
|
+
* com a que a lista usa para aquele mesmo status.
|
|
42
|
+
* @returns O token de cor do tema para o badge.
|
|
43
|
+
*/
|
|
44
|
+
export function statusFilterColor(theme, filter, label) {
|
|
45
|
+
// Status ESPECÍFICO da instância: reusa a heurística por nome, para o badge
|
|
46
|
+
// do filtro e o badge de cada issue da lista combinarem ("Em Andamento" é
|
|
47
|
+
// warning nos dois lugares).
|
|
48
|
+
if (typeof filter === 'number') {
|
|
49
|
+
return label === undefined ? theme.primary : statusColor(theme, label);
|
|
50
|
+
}
|
|
51
|
+
if (filter === 'open')
|
|
52
|
+
return theme.primary;
|
|
53
|
+
if (filter === 'closed')
|
|
54
|
+
return theme.success;
|
|
55
|
+
return theme.muted;
|
|
56
|
+
}
|
package/package.json
CHANGED