redmine-context 1.0.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 (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/dist/bundle/index.d.ts +5 -0
  4. package/dist/bundle/index.js +5 -0
  5. package/dist/bundle/json.d.ts +90 -0
  6. package/dist/bundle/json.js +266 -0
  7. package/dist/bundle/markdown.d.ts +75 -0
  8. package/dist/bundle/markdown.js +294 -0
  9. package/dist/bundle/search-list.d.ts +43 -0
  10. package/dist/bundle/search-list.js +53 -0
  11. package/dist/bundle/stable-stringify.d.ts +26 -0
  12. package/dist/bundle/stable-stringify.js +50 -0
  13. package/dist/cache/contract.d.ts +157 -0
  14. package/dist/cache/contract.js +0 -0
  15. package/dist/cache/disk-index.d.ts +82 -0
  16. package/dist/cache/disk-index.js +220 -0
  17. package/dist/cache/disk.d.ts +133 -0
  18. package/dist/cache/disk.js +313 -0
  19. package/dist/cache/gc.d.ts +78 -0
  20. package/dist/cache/gc.js +123 -0
  21. package/dist/cache/get-or-compute.d.ts +36 -0
  22. package/dist/cache/get-or-compute.js +52 -0
  23. package/dist/cache/index.d.ts +9 -0
  24. package/dist/cache/index.js +8 -0
  25. package/dist/cache/keys.d.ts +76 -0
  26. package/dist/cache/keys.js +78 -0
  27. package/dist/cache/memory.d.ts +48 -0
  28. package/dist/cache/memory.js +110 -0
  29. package/dist/cache-first.d.ts +127 -0
  30. package/dist/cache-first.js +227 -0
  31. package/dist/client/errors.d.ts +33 -0
  32. package/dist/client/errors.js +49 -0
  33. package/dist/client/http.d.ts +110 -0
  34. package/dist/client/http.js +207 -0
  35. package/dist/client/index.d.ts +5 -0
  36. package/dist/client/index.js +5 -0
  37. package/dist/client/issues.d.ts +71 -0
  38. package/dist/client/issues.js +100 -0
  39. package/dist/client/search.d.ts +58 -0
  40. package/dist/client/search.js +81 -0
  41. package/dist/config/credentials.d.ts +247 -0
  42. package/dist/config/credentials.js +427 -0
  43. package/dist/config/doctor.d.ts +123 -0
  44. package/dist/config/doctor.js +260 -0
  45. package/dist/config/index.d.ts +6 -0
  46. package/dist/config/index.js +6 -0
  47. package/dist/config/keyring.d.ts +96 -0
  48. package/dist/config/keyring.js +158 -0
  49. package/dist/config/login.d.ts +97 -0
  50. package/dist/config/login.js +189 -0
  51. package/dist/config/settings.d.ts +94 -0
  52. package/dist/config/settings.js +140 -0
  53. package/dist/contract.d.ts +173 -0
  54. package/dist/contract.js +27 -0
  55. package/dist/core.d.ts +1 -0
  56. package/dist/core.js +8 -0
  57. package/dist/extract/audio-extractor.d.ts +105 -0
  58. package/dist/extract/audio-extractor.js +156 -0
  59. package/dist/extract/audio.d.ts +126 -0
  60. package/dist/extract/audio.js +184 -0
  61. package/dist/extract/dispatcher.d.ts +132 -0
  62. package/dist/extract/dispatcher.js +115 -0
  63. package/dist/extract/download.d.ts +111 -0
  64. package/dist/extract/download.js +261 -0
  65. package/dist/extract/duration.d.ts +106 -0
  66. package/dist/extract/duration.js +148 -0
  67. package/dist/extract/ffmpeg.d.ts +56 -0
  68. package/dist/extract/ffmpeg.js +95 -0
  69. package/dist/extract/gguf.d.ts +137 -0
  70. package/dist/extract/gguf.js +215 -0
  71. package/dist/extract/index.d.ts +19 -0
  72. package/dist/extract/index.js +19 -0
  73. package/dist/extract/magic.d.ts +80 -0
  74. package/dist/extract/magic.js +282 -0
  75. package/dist/extract/ooxml.d.ts +131 -0
  76. package/dist/extract/ooxml.js +336 -0
  77. package/dist/extract/pdf.d.ts +147 -0
  78. package/dist/extract/pdf.js +322 -0
  79. package/dist/extract/queue.d.ts +167 -0
  80. package/dist/extract/queue.js +217 -0
  81. package/dist/extract/subprocess.d.ts +145 -0
  82. package/dist/extract/subprocess.js +181 -0
  83. package/dist/extract/tesseract.d.ts +153 -0
  84. package/dist/extract/tesseract.js +321 -0
  85. package/dist/extract/video-extractor.d.ts +84 -0
  86. package/dist/extract/video-extractor.js +89 -0
  87. package/dist/extract/video.d.ts +198 -0
  88. package/dist/extract/video.js +418 -0
  89. package/dist/extract/which.d.ts +63 -0
  90. package/dist/extract/which.js +72 -0
  91. package/dist/extract/whisper-extract.d.ts +211 -0
  92. package/dist/extract/whisper-extract.js +323 -0
  93. package/dist/extract/whisper.d.ts +50 -0
  94. package/dist/extract/whisper.js +67 -0
  95. package/dist/extract/zip.d.ts +50 -0
  96. package/dist/extract/zip.js +165 -0
  97. package/dist/extract-issue-attachments.d.ts +82 -0
  98. package/dist/extract-issue-attachments.js +156 -0
  99. package/dist/fetch-attachment-text.d.ts +113 -0
  100. package/dist/fetch-attachment-text.js +155 -0
  101. package/dist/fetch-issue-bundle.d.ts +81 -0
  102. package/dist/fetch-issue-bundle.js +93 -0
  103. package/dist/fetch-issue-search.d.ts +74 -0
  104. package/dist/fetch-issue-search.js +120 -0
  105. package/dist/index.d.ts +21 -0
  106. package/dist/index.js +67 -0
  107. package/dist/normalize/collections.d.ts +52 -0
  108. package/dist/normalize/collections.js +170 -0
  109. package/dist/normalize/helpers.d.ts +35 -0
  110. package/dist/normalize/helpers.js +59 -0
  111. package/dist/normalize/index.d.ts +2 -0
  112. package/dist/normalize/index.js +2 -0
  113. package/dist/normalize/issue.d.ts +34 -0
  114. package/dist/normalize/issue.js +154 -0
  115. package/dist/surfaces/cli/commands.d.ts +61 -0
  116. package/dist/surfaces/cli/commands.js +262 -0
  117. package/dist/surfaces/cli/main.d.ts +32 -0
  118. package/dist/surfaces/cli/main.js +210 -0
  119. package/dist/surfaces/cli/prompts.d.ts +66 -0
  120. package/dist/surfaces/cli/prompts.js +147 -0
  121. package/dist/surfaces/cli/tty.d.ts +27 -0
  122. package/dist/surfaces/cli/tty.js +35 -0
  123. package/dist/surfaces/cli/types.d.ts +39 -0
  124. package/dist/surfaces/cli/types.js +7 -0
  125. package/dist/surfaces/mcp/server.d.ts +171 -0
  126. package/dist/surfaces/mcp/server.js +427 -0
  127. package/dist/surfaces/tui/app.d.ts +55 -0
  128. package/dist/surfaces/tui/app.js +180 -0
  129. package/dist/surfaces/tui/attachment-status.d.ts +79 -0
  130. package/dist/surfaces/tui/attachment-status.js +113 -0
  131. package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
  132. package/dist/surfaces/tui/components/breadcrumb.js +31 -0
  133. package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
  134. package/dist/surfaces/tui/components/gradient-text.js +75 -0
  135. package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
  136. package/dist/surfaces/tui/components/scroll-view.js +77 -0
  137. package/dist/surfaces/tui/components/spinner.d.ts +12 -0
  138. package/dist/surfaces/tui/components/spinner.js +39 -0
  139. package/dist/surfaces/tui/components/text-input.d.ts +45 -0
  140. package/dist/surfaces/tui/components/text-input.js +114 -0
  141. package/dist/surfaces/tui/format-file-size.d.ts +24 -0
  142. package/dist/surfaces/tui/format-file-size.js +43 -0
  143. package/dist/surfaces/tui/glyphs.d.ts +47 -0
  144. package/dist/surfaces/tui/glyphs.js +84 -0
  145. package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
  146. package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
  147. package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
  148. package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
  149. package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
  150. package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
  151. package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
  152. package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
  153. package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
  154. package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
  155. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
  156. package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
  157. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
  158. package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
  159. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
  160. package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
  161. package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
  162. package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
  163. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
  164. package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
  165. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
  166. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
  167. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
  168. package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
  169. package/dist/surfaces/tui/index.d.ts +50 -0
  170. package/dist/surfaces/tui/index.js +158 -0
  171. package/dist/surfaces/tui/instance.d.ts +37 -0
  172. package/dist/surfaces/tui/instance.js +36 -0
  173. package/dist/surfaces/tui/job-registry.d.ts +113 -0
  174. package/dist/surfaces/tui/job-registry.js +123 -0
  175. package/dist/surfaces/tui/job-status.d.ts +45 -0
  176. package/dist/surfaces/tui/job-status.js +81 -0
  177. package/dist/surfaces/tui/navigation.d.ts +74 -0
  178. package/dist/surfaces/tui/navigation.js +87 -0
  179. package/dist/surfaces/tui/palettes.d.ts +38 -0
  180. package/dist/surfaces/tui/palettes.js +244 -0
  181. package/dist/surfaces/tui/screen.d.ts +30 -0
  182. package/dist/surfaces/tui/screen.js +51 -0
  183. package/dist/surfaces/tui/screens/about.d.ts +2 -0
  184. package/dist/surfaces/tui/screens/about.js +35 -0
  185. package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
  186. package/dist/surfaces/tui/screens/appearance.js +74 -0
  187. package/dist/surfaces/tui/screens/config.d.ts +2 -0
  188. package/dist/surfaces/tui/screens/config.js +82 -0
  189. package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
  190. package/dist/surfaces/tui/screens/doctor.js +109 -0
  191. package/dist/surfaces/tui/screens/export.d.ts +2 -0
  192. package/dist/surfaces/tui/screens/export.js +168 -0
  193. package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
  194. package/dist/surfaces/tui/screens/home-selection.js +80 -0
  195. package/dist/surfaces/tui/screens/home.d.ts +2 -0
  196. package/dist/surfaces/tui/screens/home.js +200 -0
  197. package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
  198. package/dist/surfaces/tui/screens/issue-detail.js +182 -0
  199. package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
  200. package/dist/surfaces/tui/screens/jobs.js +89 -0
  201. package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
  202. package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
  203. package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
  204. package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
  205. package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
  206. package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
  207. package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
  208. package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
  209. package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
  210. package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
  211. package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
  212. package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
  213. package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
  214. package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
  215. package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
  216. package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
  217. package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
  218. package/dist/surfaces/tui/screens/welcome.js +95 -0
  219. package/dist/surfaces/tui/status-color.d.ts +21 -0
  220. package/dist/surfaces/tui/status-color.js +23 -0
  221. package/dist/surfaces/tui/symbols.d.ts +231 -0
  222. package/dist/surfaces/tui/symbols.js +14 -0
  223. package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
  224. package/dist/surfaces/tui/terminal-colors.js +39 -0
  225. package/dist/surfaces/tui/theme.d.ts +156 -0
  226. package/dist/surfaces/tui/theme.js +86 -0
  227. package/dist/surfaces/tui/truncate.d.ts +31 -0
  228. package/dist/surfaces/tui/truncate.js +81 -0
  229. package/package.json +93 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Lógica de dados da busca inline da home (M2-07, issue #30): busca textual
3
+ * + filtro rápido de status via `fetchIssueSearch` do core — orquestração
4
+ * que já combina filtros estruturados (`assigned_to_id=me` + `status_id`)
5
+ * com full-text best-effort (`/search.json`, com degradação sinalizada no
6
+ * payload quando indisponível). Extraído para um hook próprio (em vez de
7
+ * estender `use-my-issues.ts`) porque o gatilho é bem diferente: `useMyIssues`
8
+ * busca uma vez ao montar, enquanto esta busca é retriggada a cada mudança de
9
+ * `query`/`statusFilter` — mas reaproveita deliberadamente o MESMO formato de
10
+ * `MyIssuesState` (`loading`/`error-network`/`error-forbidden`/`auth-aborted`
11
+ * + um estado "carregado") para a tela (`../screens/home.tsx`) tratar os dois
12
+ * hooks de forma consistente.
13
+ *
14
+ * Fronteira do core (ADR-005): a única coisa importada daqui é
15
+ * `../../../index.js` (`fetchIssueSearch`/`resolveApiKey`) — a tela só
16
+ * formata os estados, nunca chama o core diretamente.
17
+ *
18
+ * Debounce de digitação: `query` normalmente muda a cada tecla (controlado
19
+ * pelo `TextInput` da home); disparar `fetchIssueSearch` a cada tecla geraria
20
+ * uma chamada de rede por caractere digitado. Este hook espera
21
+ * `debounceMs` (300ms em produção, {@link DEFAULT_DEBOUNCE_MS}) de silêncio
22
+ * antes de efetivamente buscar — implementado com um `setTimeout` reagendado
23
+ * a cada mudança de `query` (cancelado no cleanup do efeito).
24
+ *
25
+ * 401 via `useAuthGuard` (M2-13, #36, mandatório para qualquer chamada
26
+ * autenticada ao core): a busca é envolvida por `guard()`, mesmo padrão de
27
+ * `use-my-issues.ts` — em 401, o estado permanece `loading` durante o
28
+ * re-auth e a busca é refeita automaticamente após o re-login. Abandono do
29
+ * re-login (Esc) vira o estado `auth-aborted`.
30
+ */
31
+ import { useEffect, useRef, useState } from 'react';
32
+ import { fetchIssueSearch, resolveApiKey, RedmineForbiddenError, } from '../../../index.js';
33
+ import { useEnvFallbackAllowed } from '../instance.js';
34
+ import { ReAuthAbortedError, useAuthGuard } from './use-auth-guard.js';
35
+ /** Debounce (ms) default aplicado a `query` antes de disparar a busca. */
36
+ export const DEFAULT_DEBOUNCE_MS = 300;
37
+ /** Mapeia o filtro rápido de status para o `status_id` de `/issues.json` (`*` = todas). */
38
+ function statusIdFor(filter) {
39
+ if (filter === 'open')
40
+ return 'open';
41
+ if (filter === 'closed')
42
+ return 'closed';
43
+ return '*';
44
+ }
45
+ /** Lê `REDMINE_URL` do ambiente, tratando string vazia como ausente (mesma convenção do doctor/home). */
46
+ function instanceFromEnv(env) {
47
+ const value = env.REDMINE_URL;
48
+ return value !== undefined && value.length > 0 ? value : undefined;
49
+ }
50
+ /**
51
+ * Busca issues (filtros + full-text best-effort) via o core, com debounce de
52
+ * digitação e o filtro rápido de status.
53
+ *
54
+ * @param query - Termo full-text bruto (não debounced) — tipicamente o
55
+ * `value` do `TextInput` da busca da home.
56
+ * @param statusFilter - Filtro rápido de status (ver {@link SearchStatusFilter}).
57
+ * @param options - Dependências injetáveis. Ver {@link UseIssueSearchOptions}.
58
+ * @returns O {@link UseIssueSearchResult} atual.
59
+ *
60
+ * @example
61
+ * const { state, clear } = useIssueSearch(query, statusFilter); // deps de produção
62
+ */
63
+ export function useIssueSearch(query, statusFilter, options = {}) {
64
+ const env = options.env ?? process.env;
65
+ const resolve = options.resolveApiKey ?? resolveApiKey;
66
+ const runSearch = options.fetchIssueSearch ?? fetchIssueSearch;
67
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
68
+ // Fix do padrão #120 (use-my-issues.ts): qualquer chamada autenticada ao
69
+ // core passa por `guard()` — 401 dispara o re-login e retoma a busca.
70
+ const { guard } = useAuthGuard();
71
+ // SEGURANÇA (#187): origem config (URL persistida) → sem env-key instance-agnóstica.
72
+ const allowEnvFallback = useEnvFallbackAllowed();
73
+ const [state, setState] = useState({ status: 'idle' });
74
+ const [debouncedQuery, setDebouncedQuery] = useState(query);
75
+ // Debounce: só propaga `query` para `debouncedQuery` depois de `debounceMs`
76
+ // de silêncio — reagendado a cada tecla (cleanup cancela o timer anterior).
77
+ useEffect(() => {
78
+ const timer = setTimeout(() => {
79
+ setDebouncedQuery(query);
80
+ }, debounceMs);
81
+ return () => {
82
+ clearTimeout(timer);
83
+ };
84
+ }, [query, debounceMs]);
85
+ useEffect(() => {
86
+ let cancelled = false;
87
+ const trimmed = debouncedQuery.trim();
88
+ // Nada para buscar: sem texto E sem filtro de status ativo. `idle` evita
89
+ // uma chamada de rede assim que o campo de busca abre vazio.
90
+ if (trimmed.length === 0 && statusFilter === 'all') {
91
+ setState({ status: 'idle' });
92
+ return;
93
+ }
94
+ setState({ status: 'loading' });
95
+ const instanceUrl = instanceFromEnv(env);
96
+ if (instanceUrl === undefined) {
97
+ setState({
98
+ status: 'error-network',
99
+ message: 'Instância não configurada (defina REDMINE_URL).',
100
+ });
101
+ return;
102
+ }
103
+ const cascadeOptions = { env, allowEnvFallback };
104
+ void (async () => {
105
+ try {
106
+ const apiKey = await resolve(instanceUrl, cascadeOptions);
107
+ if (apiKey === undefined) {
108
+ if (!cancelled) {
109
+ setState({
110
+ status: 'error-network',
111
+ message: `Nenhuma credencial encontrada para ${instanceUrl}.`,
112
+ });
113
+ }
114
+ return;
115
+ }
116
+ const result = await guard(() => runSearch({
117
+ baseUrl: instanceUrl,
118
+ apiKey,
119
+ filters: { assigned_to_id: 'me', status_id: statusIdFor(statusFilter) },
120
+ query: trimmed.length > 0 ? trimmed : undefined,
121
+ }));
122
+ if (cancelled)
123
+ return;
124
+ setState({
125
+ status: 'loaded',
126
+ content: result.content,
127
+ count: result.count,
128
+ degraded: result.degraded,
129
+ warnings: result.warnings,
130
+ });
131
+ }
132
+ catch (cause) {
133
+ if (cancelled)
134
+ return;
135
+ if (cause instanceof RedmineForbiddenError) {
136
+ setState({
137
+ status: 'error-forbidden',
138
+ message: 'Sem permissão para buscar issues nesta instância (403).',
139
+ });
140
+ return;
141
+ }
142
+ if (cause instanceof ReAuthAbortedError) {
143
+ setState({
144
+ status: 'auth-aborted',
145
+ message: 'login cancelado — pressione f/digite de novo para tentar',
146
+ });
147
+ return;
148
+ }
149
+ const message = cause instanceof Error ? cause.message : String(cause);
150
+ setState({ status: 'error-network', message });
151
+ }
152
+ })();
153
+ return () => {
154
+ cancelled = true;
155
+ };
156
+ // Reason: `env`/`resolve`/`runSearch`/`guard` são estáveis entre renders
157
+ // com as deps de produção (defaults do módulo, ou `process.env`, ou a
158
+ // identidade estável de `useAuthGuard().guard`) — o efeito só precisa
159
+ // refazer a busca quando `debouncedQuery`/`statusFilter` mudam.
160
+ }, [debouncedQuery, statusFilter, env, allowEnvFallback, resolve, runSearch, guard]);
161
+ // Handler ESTÁVEL (useCallback via ref indireta não é necessário aqui: sem
162
+ // dependências externas mutáveis, `clear` já é estável por construção).
163
+ const clear = useRef(() => {
164
+ setDebouncedQuery('');
165
+ setState({ status: 'idle' });
166
+ }).current;
167
+ return { state, clear };
168
+ }
@@ -0,0 +1,44 @@
1
+ import { type Dispatch, type SetStateAction } from 'react';
2
+ /** Opções do hook — todas opcionais. */
3
+ export interface UseListNavigationOptions {
4
+ /**
5
+ * Chamado com o índice atual quando o usuário confirma a seleção (Enter).
6
+ * Opcional: uma lista somente de exibição pode omitir e ler apenas
7
+ * `selectedIndex`.
8
+ */
9
+ onSelect?: (index: number) => void;
10
+ /**
11
+ * Desliga a captura de teclado deste hook quando `false` (mesmo padrão de
12
+ * `isActive` do `../components/text-input.tsx`) — necessário quando outro
13
+ * campo da mesma tela está temporariamente "dono" do teclado (ex.: a busca
14
+ * inline da home, M2-07/#30). Default: `true`.
15
+ */
16
+ isActive?: boolean;
17
+ /**
18
+ * Cursor inicial — default `0`. Aplicado assim que `itemCount` deixa de
19
+ * ser `0` pela primeira vez (clampado); mudanças subsequentes NÃO
20
+ * reposicionam o cursor (preservação de seleção entre remounts — #31).
21
+ */
22
+ initialIndex?: number;
23
+ }
24
+ /** Valor retornado pelo hook. */
25
+ export interface UseListNavigationResult {
26
+ /** Índice selecionado (0-based), sempre dentro de `[0, itemCount - 1]`. */
27
+ selectedIndex: number;
28
+ /** Setter direto — útil para resetar a seleção quando a lista troca de conteúdo (ex.: novo filtro). */
29
+ setSelectedIndex: Dispatch<SetStateAction<number>>;
30
+ }
31
+ /**
32
+ * Hook de navegação em lista: setas/`j`/`k` movem `selectedIndex` com wrap,
33
+ * Enter confirma via `onSelect`.
34
+ *
35
+ * @param itemCount Total de itens navegáveis. `0` desativa a navegação (sem
36
+ * quebrar — apenas nada acontece, a lista está vazia).
37
+ * @param options Ver {@link UseListNavigationOptions}.
38
+ *
39
+ * @example
40
+ * const { selectedIndex } = useListNavigation(issues.length, {
41
+ * onSelect: (index) => push('issue-detail'),
42
+ * });
43
+ */
44
+ export declare function useListNavigation(itemCount: number, options?: UseListNavigationOptions): UseListNavigationResult;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Padrão reutilizável de navegação em listas da TUI (M2-04).
3
+ *
4
+ * Encapsula o `useInput()` comum a toda lista selecionável: setas ↑/↓ (ou
5
+ * `j`/`k`, atalhos vim-like opcionais) movem `selectedIndex` com wrap nas
6
+ * pontas; Enter confirma via `onSelect`. Cada tela de lista (M2-06 é a
7
+ * primeira a consumir isso) só precisa chamar o hook com o total de itens —
8
+ * nenhuma tela reimplementa esse teclado do zero.
9
+ *
10
+ * Esc (voltar) e Ctrl+C (sair) NÃO são tratados aqui — são atalhos globais
11
+ * do roteador (`../app.tsx`, `use-exit-guard.ts`), fora do escopo de uma
12
+ * lista específica.
13
+ */
14
+ import { useInput } from 'ink';
15
+ import { useEffect, useRef, useState } from 'react';
16
+ /**
17
+ * Hook de navegação em lista: setas/`j`/`k` movem `selectedIndex` com wrap,
18
+ * Enter confirma via `onSelect`.
19
+ *
20
+ * @param itemCount Total de itens navegáveis. `0` desativa a navegação (sem
21
+ * quebrar — apenas nada acontece, a lista está vazia).
22
+ * @param options Ver {@link UseListNavigationOptions}.
23
+ *
24
+ * @example
25
+ * const { selectedIndex } = useListNavigation(issues.length, {
26
+ * onSelect: (index) => push('issue-detail'),
27
+ * });
28
+ */
29
+ export function useListNavigation(itemCount, options = {}) {
30
+ const { onSelect, isActive = true, initialIndex } = options;
31
+ // Ref (não dep de efeito): a aplicação do `initialIndex` deve acontecer uma
32
+ // única vez, na primeira vez que a lista deixa de estar vazia — mesmo que
33
+ // `initialIndex` mude entre renders (ex.: o contexto de origem re-renderiza
34
+ // por outro motivo).
35
+ const appliedInitialRef = useRef(false);
36
+ const [selectedIndex, setSelectedIndex] = useState(() => {
37
+ if (itemCount <= 0) {
38
+ // Nada para clampar ainda — o efeito abaixo aplica `initialIndex` assim
39
+ // que `itemCount` deixar de ser 0 (ex.: lista ainda carregando).
40
+ return 0;
41
+ }
42
+ appliedInitialRef.current = true;
43
+ return Math.min(Math.max(initialIndex ?? 0, 0), itemCount - 1);
44
+ });
45
+ useInput((input, key) => {
46
+ if (itemCount <= 0) {
47
+ return;
48
+ }
49
+ if (key.upArrow || input === 'k') {
50
+ setSelectedIndex((current) => (current - 1 + itemCount) % itemCount);
51
+ return;
52
+ }
53
+ if (key.downArrow || input === 'j') {
54
+ setSelectedIndex((current) => (current + 1) % itemCount);
55
+ return;
56
+ }
57
+ if (key.return) {
58
+ onSelect?.(selectedIndex);
59
+ }
60
+ }, { isActive });
61
+ // Reason: mantém `selectedIndex` dentro dos limites quando `itemCount`
62
+ // encolhe (ex.: um filtro reduz a lista) — evita um índice "fantasma" que
63
+ // aponta para fora do array de itens da tela. Também é o gatilho de
64
+ // aplicação do `initialIndex` (#31): na primeira vez que `itemCount` deixa
65
+ // de ser 0, usa `initialIndex` (se informado) como base em vez do cursor
66
+ // atual — cobre tanto o caso síncrono (lista já não-vazia no primeiro
67
+ // render) quanto o assíncrono (lista carrega depois, ex.: `home.tsx`).
68
+ useEffect(() => {
69
+ setSelectedIndex((current) => {
70
+ if (itemCount <= 0) {
71
+ return 0;
72
+ }
73
+ const base = !appliedInitialRef.current && initialIndex !== undefined ? initialIndex : current;
74
+ appliedInitialRef.current = true;
75
+ return Math.min(Math.max(base, 0), itemCount - 1);
76
+ });
77
+ // Reason: `initialIndex` é intencionalmente OMITIDO das deps — é lido só
78
+ // na aplicação única controlada por `appliedInitialRef`, nunca deve
79
+ // reexecutar este efeito por si só (ver o JSDoc da opção).
80
+ }, [itemCount]);
81
+ return { selectedIndex, setSelectedIndex };
82
+ }
@@ -0,0 +1,24 @@
1
+ import { diagnoseBinaries, type BinaryDiagnosis } from '../../../index.js';
2
+ /** Estado da seção "Binários de mídia": carregando + o resultado do diagnóstico. */
3
+ export interface MediaBinariesState {
4
+ /** `true` enquanto o diagnóstico assíncrono ainda não resolveu. */
5
+ loading: boolean;
6
+ /** Binários diagnosticados (vazio até resolver, ou se o diagnóstico falhar). */
7
+ binaries: BinaryDiagnosis[];
8
+ }
9
+ /** Dependências injetáveis do hook — todas opcionais, com defaults de produção. */
10
+ export interface UseMediaBinariesOptions {
11
+ /** Executa o diagnóstico; default `diagnoseBinaries` do core. */
12
+ diagnose?: typeof diagnoseBinaries;
13
+ }
14
+ /**
15
+ * Diagnostica os binários de mídia uma vez, no mount. Nunca lança: se o
16
+ * diagnóstico rejeitar, sai do loading com uma lista vazia (a tela apenas não
17
+ * exibe binários — degradação graciosa).
18
+ *
19
+ * @param options - Deps injetáveis. Ver {@link UseMediaBinariesOptions}.
20
+ * @returns O {@link MediaBinariesState} atual — começa em `loading: true`.
21
+ * @example
22
+ * const { loading, binaries } = useMediaBinaries();
23
+ */
24
+ export declare function useMediaBinaries(options?: UseMediaBinariesOptions): MediaBinariesState;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Lógica assíncrona da seção "Binários de mídia" da tela doctor (M3-11, #53).
3
+ *
4
+ * Extraída para um hook porque o diagnóstico (`diagnoseBinaries`) é assíncrono
5
+ * (localiza binários no filesystem e lê versão via subprocesso) e precisa ser
6
+ * testável com a dependência injetada — nenhum teste deste arquivo consulta um
7
+ * binário real.
8
+ *
9
+ * Fronteira do core (ADR-005): a única coisa importada daqui é `diagnoseBinaries`
10
+ * via `../../../index.js` — a mesma superfície pública que a CLI consome para o
11
+ * comando `doctor`. Sem reimplementar detecção na TUI.
12
+ */
13
+ import { useEffect, useState } from 'react';
14
+ import { diagnoseBinaries } from '../../../index.js';
15
+ /**
16
+ * Diagnostica os binários de mídia uma vez, no mount. Nunca lança: se o
17
+ * diagnóstico rejeitar, sai do loading com uma lista vazia (a tela apenas não
18
+ * exibe binários — degradação graciosa).
19
+ *
20
+ * @param options - Deps injetáveis. Ver {@link UseMediaBinariesOptions}.
21
+ * @returns O {@link MediaBinariesState} atual — começa em `loading: true`.
22
+ * @example
23
+ * const { loading, binaries } = useMediaBinaries();
24
+ */
25
+ export function useMediaBinaries(options = {}) {
26
+ const diagnose = options.diagnose ?? diagnoseBinaries;
27
+ const [state, setState] = useState({ loading: true, binaries: [] });
28
+ useEffect(() => {
29
+ let cancelled = false;
30
+ diagnose().then((binaries) => {
31
+ if (!cancelled)
32
+ setState({ loading: false, binaries });
33
+ }, () => {
34
+ if (!cancelled)
35
+ setState({ loading: false, binaries: [] });
36
+ });
37
+ return () => {
38
+ cancelled = true;
39
+ };
40
+ // Reason: `diagnose` é estável entre renders (default do módulo, ou uma dep
41
+ // injetada estável no teste) — o diagnóstico só precisa rodar uma vez.
42
+ }, [diagnose]);
43
+ return state;
44
+ }
@@ -0,0 +1,66 @@
1
+ import { createHttpClient, listIssues, resolveApiKey } from '../../../index.js';
2
+ /** Issue resumida exibida numa linha da lista da home — só os campos usados por `IssueRow`. */
3
+ export interface MyIssue {
4
+ /** Id numérico da issue (`#id` na linha). */
5
+ id: number;
6
+ /** Assunto da issue (truncado na exibição, nunca aqui). */
7
+ subject: string;
8
+ /** Nome do status (badge colorido por tema). */
9
+ statusName: string;
10
+ }
11
+ /**
12
+ * Estado da busca de "minhas issues", consumido por `../screens/home.tsx`.
13
+ * `loading`/`empty`/`error-network`/`error-forbidden` são os 4 estados
14
+ * visuais distintos exigidos pela AC da #29; `loaded` é o estado "normal"
15
+ * com a lista preenchida. `auth-aborted` (fix do review #120) é um 5º
16
+ * estado leve, distinto de `error-network`: o usuário viu o prompt de
17
+ * re-login (401 tratado por `useAuthGuard`) e escolheu conscientemente (Esc)
18
+ * não reautenticar — não é uma falha inesperada, então não usa o vocabulário
19
+ * de erro; `retry` (tecla `r`) segue disponível para tentar de novo.
20
+ */
21
+ export type MyIssuesState = {
22
+ status: 'loading';
23
+ } | {
24
+ status: 'empty';
25
+ } | {
26
+ status: 'loaded';
27
+ issues: MyIssue[];
28
+ } | {
29
+ status: 'error-network';
30
+ message: string;
31
+ } | {
32
+ status: 'error-forbidden';
33
+ message: string;
34
+ } | {
35
+ status: 'auth-aborted';
36
+ message: string;
37
+ };
38
+ /** Dependências injetáveis do hook — todas opcionais, com defaults de produção via o core. */
39
+ export interface UseMyIssuesOptions {
40
+ /** Ambiente consultado para `REDMINE_URL`; default `process.env`. */
41
+ env?: NodeJS.ProcessEnv;
42
+ /** Resolve a api_key pela cascata M2; default `resolveApiKey` do core. */
43
+ resolveApiKey?: typeof resolveApiKey;
44
+ /** Constrói o client HTTP autenticado; default `createHttpClient` do core. */
45
+ createHttpClient?: typeof createHttpClient;
46
+ /** Lista issues paginando `/issues.json`; default `listIssues` do core. */
47
+ listIssues?: typeof listIssues;
48
+ }
49
+ /** Valor retornado pelo hook. */
50
+ export interface UseMyIssuesResult {
51
+ /** Estado atual da busca (ver {@link MyIssuesState}). */
52
+ state: MyIssuesState;
53
+ /** Reexecuta a busca do zero (`status` volta a `loading`) — usado pelo retry (tecla `r`) dos estados de erro. */
54
+ retry: () => void;
55
+ }
56
+ /**
57
+ * Lista "minhas issues" (`assigned_to_id=me`) via o core.
58
+ *
59
+ * @param options - Dependências injetáveis. Ver {@link UseMyIssuesOptions}.
60
+ * @returns O {@link UseMyIssuesResult} atual — `state.status` começa em
61
+ * `'loading'` e é atualizado quando a busca assíncrona resolve.
62
+ *
63
+ * @example
64
+ * const { state, retry } = useMyIssues(); // deps de produção (process.env, core real)
65
+ */
66
+ export declare function useMyIssues(options?: UseMyIssuesOptions): UseMyIssuesResult;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Lógica de dados da tela home (M2-06, #29): lista "minhas issues"
3
+ * (`assigned_to_id=me`) via o core — resolve a credencial pela cascata
4
+ * (`resolveApiKey`), monta um client HTTP autenticado (`createHttpClient`) e
5
+ * pagina `/issues.json` (`listIssues`). Extraída para um hook porque toda a
6
+ * busca é assíncrona e precisa ser testável com dependências injetadas —
7
+ * nenhum teste deste arquivo toca keychain ou rede reais.
8
+ *
9
+ * Fronteira do core (ADR-005): a única coisa importada daqui é
10
+ * `../../../index.js` — a tela (`../screens/home.tsx`) só formata os 5
11
+ * estados visuais (`loading`/`empty`/`error-network`/`error-forbidden`/
12
+ * `auth-aborted`) mais o estado "com itens" (`loaded`), nunca chama o core
13
+ * diretamente.
14
+ *
15
+ * A instância vem de `REDMINE_URL` (mesma convenção do doctor/CLI/MCP —
16
+ * ver `use-doctor-status.ts`), lida do `env` injetado (default `process.env`).
17
+ *
18
+ * Fix do review do PR #120: esta era a primeira tela de dados (#29+) a
19
+ * chamar o core autenticado SEM passar pelo `useAuthGuard` (M2-13, #36,
20
+ * mandatório para qualquer chamada assim, ver `./use-auth-guard.ts`) — um 401
21
+ * (api_key expirada/revogada) virava um `error-network` cru em vez de
22
+ * disparar o re-login e retomar a busca automaticamente. A busca (`listIssues`)
23
+ * agora é envolvida por `guard()`: em 401, o estado permanece `loading`
24
+ * durante o re-auth (a `Promise` de `guard()` só resolve/rejeita depois que o
25
+ * usuário loga de novo, ver `use-auth-guard.ts`) e a lista carrega assim que o
26
+ * re-login tem sucesso, sem ação extra do usuário. Se o re-login for
27
+ * abandonado (Esc), `guard()` rejeita com `ReAuthAbortedError` — tratado aqui
28
+ * como o estado próprio `auth-aborted` (não um erro cru), com `retry` (tecla
29
+ * `r`, já existente) disponível para tentar de novo.
30
+ */
31
+ import { useCallback, useEffect, useState } from 'react';
32
+ import { createHttpClient, listIssues, resolveApiKey, RedmineForbiddenError, } from '../../../index.js';
33
+ import { useEnvFallbackAllowed } from '../instance.js';
34
+ import { ReAuthAbortedError, useAuthGuard } from './use-auth-guard.js';
35
+ /** Estreita `unknown` para um objeto indexável, ou `undefined` se não for. */
36
+ function asRecord(value) {
37
+ return typeof value === 'object' && value !== null ? value : undefined;
38
+ }
39
+ /**
40
+ * Converte um payload bruto de issue em {@link MyIssue}; `undefined` se
41
+ * `subject`/`status.name` faltarem ou tiverem um shape inesperado — a linha
42
+ * é simplesmente omitida da lista em vez de quebrar a tela inteira.
43
+ */
44
+ function toMyIssue(raw) {
45
+ const subject = raw.subject;
46
+ const statusName = asRecord(raw.status)?.name;
47
+ if (typeof subject !== 'string' || typeof statusName !== 'string') {
48
+ return undefined;
49
+ }
50
+ return { id: raw.id, subject, statusName };
51
+ }
52
+ /** Lê `REDMINE_URL` do ambiente, tratando string vazia como ausente (mesma convenção do doctor). */
53
+ function instanceFromEnv(env) {
54
+ const value = env.REDMINE_URL;
55
+ return value !== undefined && value.length > 0 ? value : undefined;
56
+ }
57
+ /**
58
+ * Lista "minhas issues" (`assigned_to_id=me`) via o core.
59
+ *
60
+ * @param options - Dependências injetáveis. Ver {@link UseMyIssuesOptions}.
61
+ * @returns O {@link UseMyIssuesResult} atual — `state.status` começa em
62
+ * `'loading'` e é atualizado quando a busca assíncrona resolve.
63
+ *
64
+ * @example
65
+ * const { state, retry } = useMyIssues(); // deps de produção (process.env, core real)
66
+ */
67
+ export function useMyIssues(options = {}) {
68
+ const env = options.env ?? process.env;
69
+ const resolve = options.resolveApiKey ?? resolveApiKey;
70
+ const buildClient = options.createHttpClient ?? createHttpClient;
71
+ const fetchIssues = options.listIssues ?? listIssues;
72
+ // Fix do review #120: a busca é uma chamada autenticada ao core — `guard`
73
+ // (identidade estável, `useCallback` sem deps em `use-auth-guard.ts`) trata
74
+ // 401 disparando o re-login e retomando a operação automaticamente.
75
+ const { guard } = useAuthGuard();
76
+ // SEGURANÇA (#187): se a instância veio da persistida (origem config), a
77
+ // REDMINE_API_KEY instance-agnóstica NÃO pode ser usada (só credencial pinada).
78
+ const allowEnvFallback = useEnvFallbackAllowed();
79
+ const [state, setState] = useState({ status: 'loading' });
80
+ const [reloadToken, setReloadToken] = useState(0);
81
+ const retry = useCallback(() => {
82
+ setReloadToken((current) => current + 1);
83
+ }, []);
84
+ useEffect(() => {
85
+ let cancelled = false;
86
+ setState({ status: 'loading' });
87
+ const instanceUrl = instanceFromEnv(env);
88
+ if (instanceUrl === undefined) {
89
+ setState({
90
+ status: 'error-network',
91
+ message: 'Instância não configurada (defina REDMINE_URL).',
92
+ });
93
+ return;
94
+ }
95
+ const cascadeOptions = { env, allowEnvFallback };
96
+ void (async () => {
97
+ try {
98
+ const apiKey = await resolve(instanceUrl, cascadeOptions);
99
+ if (apiKey === undefined) {
100
+ if (!cancelled) {
101
+ setState({
102
+ status: 'error-network',
103
+ message: `Nenhuma credencial encontrada para ${instanceUrl}.`,
104
+ });
105
+ }
106
+ return;
107
+ }
108
+ const http = buildClient({ baseUrl: instanceUrl, apiKey });
109
+ // Fix do review #120: envolvida por `guard()` — em 401, a Promise só
110
+ // resolve após o re-login ter sucesso e a busca ser refeita
111
+ // automaticamente; o estado do hook permanece `loading` enquanto isso.
112
+ const raw = await guard(() => fetchIssues(http, { filters: { assigned_to_id: 'me' } }));
113
+ if (cancelled)
114
+ return;
115
+ const issues = raw.map(toMyIssue).filter((issue) => issue !== undefined);
116
+ setState(issues.length === 0 ? { status: 'empty' } : { status: 'loaded', issues });
117
+ }
118
+ catch (cause) {
119
+ if (cancelled)
120
+ return;
121
+ if (cause instanceof RedmineForbiddenError) {
122
+ setState({
123
+ status: 'error-forbidden',
124
+ message: 'Sem permissão para listar suas issues nesta instância (403).',
125
+ });
126
+ return;
127
+ }
128
+ if (cause instanceof ReAuthAbortedError) {
129
+ // Fix do review #120: abandono consciente do re-login (Esc) — estado
130
+ // neutro dedicado, não um erro cru. `retry` (tecla `r`, já existente
131
+ // na tela) refaz a busca do zero.
132
+ setState({
133
+ status: 'auth-aborted',
134
+ message: 'login cancelado — pressione r para tentar de novo',
135
+ });
136
+ return;
137
+ }
138
+ const message = cause instanceof Error ? cause.message : String(cause);
139
+ setState({ status: 'error-network', message });
140
+ }
141
+ })();
142
+ return () => {
143
+ cancelled = true;
144
+ };
145
+ // Reason: `env`/`resolve`/`buildClient`/`fetchIssues`/`guard` são estáveis
146
+ // entre renders com as deps de produção (defaults do módulo, ou
147
+ // `process.env`, ou a identidade estável de `useAuthGuard().guard`) — o
148
+ // efeito só precisa refazer a busca quando `reloadToken` muda (retry).
149
+ }, [env, allowEnvFallback, resolve, buildClient, fetchIssues, guard, reloadToken]);
150
+ return { state, retry };
151
+ }
@@ -0,0 +1,11 @@
1
+ import { type SettingsStore } from '../../../index.js';
2
+ import type { OnboardingCallbacks } from '../screens/onboarding/onboarding-context.js';
3
+ /**
4
+ * Monta os callbacks reais de onboarding — injetados em
5
+ * `<OnboardingProvider callbacks={...}>` por `../app.tsx`.
6
+ *
7
+ * @example
8
+ * const callbacks = useOnboardingCallbacks();
9
+ * return <OnboardingProvider callbacks={callbacks}>...</OnboardingProvider>;
10
+ */
11
+ export declare function useOnboardingCallbacks(settings?: SettingsStore): OnboardingCallbacks;