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,24 @@
1
+ /**
2
+ * Formatação humanizada de tamanho de arquivo (#32) — usada pela seção de
3
+ * anexos do detalhe da issue para exibir `Attachment.filesize` (bytes,
4
+ * inteiro bruto do Redmine) em um texto curto (B/KB/MB/GB), sem depender de
5
+ * biblioteca externa para uma conta tão simples.
6
+ */
7
+ /**
8
+ * Formata `bytes` como texto humanizado (ex.: `999B`, `1.5KB`, `2MB`).
9
+ *
10
+ * Escala para a maior unidade em que o valor fica `>= 1` (mas `< 1024`),
11
+ * arredondando para 1 casa decimal — sem casa decimal supérflua quando o
12
+ * valor é inteiro (`1KB`, não `1.0KB`).
13
+ *
14
+ * @param bytes - Tamanho em bytes (`Attachment.filesize`). Valores negativos
15
+ * nunca deveriam ocorrer (o core normaliza para `>= 0`), mas são tratados
16
+ * defensivamente como `0` em vez de lançar — consistente com o resto da
17
+ * normalização (ADR-005: nunca crashar por payload inesperado).
18
+ * @returns O texto humanizado.
19
+ * @example
20
+ * humanizeFileSize(999) // "999B"
21
+ * humanizeFileSize(1536) // "1.5KB"
22
+ * humanizeFileSize(2 * 1024 * 1024) // "2MB"
23
+ */
24
+ export declare function humanizeFileSize(bytes: number): string;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Formatação humanizada de tamanho de arquivo (#32) — usada pela seção de
3
+ * anexos do detalhe da issue para exibir `Attachment.filesize` (bytes,
4
+ * inteiro bruto do Redmine) em um texto curto (B/KB/MB/GB), sem depender de
5
+ * biblioteca externa para uma conta tão simples.
6
+ */
7
+ /** Unidades usadas na escalada progressiva (índice 0 = bytes, sem sufixo de escala). */
8
+ const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
9
+ /**
10
+ * Formata `bytes` como texto humanizado (ex.: `999B`, `1.5KB`, `2MB`).
11
+ *
12
+ * Escala para a maior unidade em que o valor fica `>= 1` (mas `< 1024`),
13
+ * arredondando para 1 casa decimal — sem casa decimal supérflua quando o
14
+ * valor é inteiro (`1KB`, não `1.0KB`).
15
+ *
16
+ * @param bytes - Tamanho em bytes (`Attachment.filesize`). Valores negativos
17
+ * nunca deveriam ocorrer (o core normaliza para `>= 0`), mas são tratados
18
+ * defensivamente como `0` em vez de lançar — consistente com o resto da
19
+ * normalização (ADR-005: nunca crashar por payload inesperado).
20
+ * @returns O texto humanizado.
21
+ * @example
22
+ * humanizeFileSize(999) // "999B"
23
+ * humanizeFileSize(1536) // "1.5KB"
24
+ * humanizeFileSize(2 * 1024 * 1024) // "2MB"
25
+ */
26
+ export function humanizeFileSize(bytes) {
27
+ const safeBytes = bytes > 0 ? bytes : 0;
28
+ let value = safeBytes;
29
+ let unitIndex = 0;
30
+ while (value >= 1024 && unitIndex < UNITS.length - 1) {
31
+ value /= 1024;
32
+ unitIndex += 1;
33
+ }
34
+ let rounded = Math.round(value * 10) / 10;
35
+ // O arredondamento pode "estourar" a unidade (1048575 bytes → 1024KB):
36
+ // nesse caso, escala mais um degrau para exibir 1MB, não 1024KB.
37
+ if (rounded >= 1024 && unitIndex < UNITS.length - 1) {
38
+ rounded = Math.round((rounded / 1024) * 10) / 10;
39
+ unitIndex += 1;
40
+ }
41
+ const formatted = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
42
+ return `${formatted}${UNITS[unitIndex]}`;
43
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Espelha a heurística de `is-unicode-supported@2` (a mesma que `figures`
3
+ * consome) como função pura e injetável.
4
+ *
5
+ * Reason: `figures` decide o fallback UMA vez, no import, a partir do ambiente
6
+ * real — para orçarmos os glyphs "common" pelo MESMO critério (e testá-lo com
7
+ * ambientes simulados) precisamos da decisão exposta como função. Manter a
8
+ * lógica idêntica à da lib garante que os dois caminhos concordem.
9
+ *
10
+ * @param env - Ambiente do processo (ou um subconjunto controlado em testes).
11
+ * @param platform - Plataforma (`process.platform`); injetável para testes.
12
+ * @returns `true` quando o terminal deve renderizar glyphs Unicode.
13
+ * @example
14
+ * isUnicodeSupported({ WT_SESSION: '1' }, 'win32'); // true (Windows Terminal)
15
+ * isUnicodeSupported({}, 'win32'); // false (cmd.exe/PowerShell legado)
16
+ */
17
+ export declare function isUnicodeSupported(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
18
+ /** Conjunto de glyphs "common" que a TUI usa fora de `figures`. */
19
+ export interface Glyphs {
20
+ /** Marcador de corte do truncamento (`../truncate.ts`). */
21
+ readonly ellipsis: string;
22
+ /** Separador entre campos de uma linha (ex.: `tamanho · tipo`). */
23
+ readonly middleDot: string;
24
+ /** Placeholder para campos ausentes (assignee, datas, old/new value). */
25
+ readonly emptyPlaceholder: string;
26
+ /** Seta "para cima" das dicas de navegação. */
27
+ readonly arrowUp: string;
28
+ /** Seta "para baixo" das dicas de navegação. */
29
+ readonly arrowDown: string;
30
+ /** Caractere da máscara de senha/api_key (`components/text-input.tsx`). */
31
+ readonly maskBullet: string;
32
+ /** Frames do spinner (`components/spinner.tsx`). */
33
+ readonly spinnerFrames: readonly string[];
34
+ }
35
+ /** Glyphs Unicode — terminais modernos (Windows Terminal, iTerm, etc.). */
36
+ export declare const UNICODE_GLYPHS: Glyphs;
37
+ /** Fallback ASCII puro — terminal legado do Windows (sem mojibake). */
38
+ export declare const ASCII_GLYPHS: Glyphs;
39
+ /**
40
+ * Escolhe o conjunto de glyphs conforme o suporte a Unicode.
41
+ *
42
+ * @param unicode - `true` para glyphs Unicode, `false` para o fallback ASCII.
43
+ * @returns O conjunto de glyphs correspondente.
44
+ */
45
+ export declare function resolveGlyphs(unicode: boolean): Glyphs;
46
+ /** Glyphs resolvidos para o ambiente atual — importado pelas telas/utilitários. */
47
+ export declare const glyphs: Glyphs;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Glyphs NÃO cobertos por `figures` — hardening do terminal LEGADO do Windows
3
+ * (M5-09, #84): cmd.exe / PowerShell antigo, sem suporte a Unicode.
4
+ *
5
+ * `figures` (ver `./symbols.ts`) só degrada para ASCII os seus símbolos
6
+ * ESPECIAIS (tick/cross/pointer/info/warning). Os glyphs da categoria "common"
7
+ * dele — reticências `…`, setas `↑`/`↓`, `bullet`, etc. — permanecem Unicode
8
+ * MESMO no conjunto `fallbackSymbols` (confira `node_modules/figures/index.js`:
9
+ * `common` é espalhado tanto em `mainSymbols` quanto em `fallbackSymbols`).
10
+ *
11
+ * A TUI usa alguns desses glyphs "common" hardcoded fora do sistema de
12
+ * `figures` (reticências de truncamento, setas de navegação, o separador `·`,
13
+ * a máscara de senha `•` e os frames braille do spinner). Em um terminal
14
+ * legado do Windows eles apareceriam como mojibake. Este módulo centraliza
15
+ * ESSES glyphs com um fallback ASCII, decidido pelo MESMO sinal que `figures`
16
+ * usa por baixo (`is-unicode-supported`), replicado aqui como função pura e
17
+ * injetável (`env`/`platform`) — seguindo o padrão de `../cli/tty.ts`
18
+ * (`shouldRenderTui`), que também mantém a decisão de degradação testável sem
19
+ * ler `process` direto.
20
+ */
21
+ import process from 'node:process';
22
+ /**
23
+ * Espelha a heurística de `is-unicode-supported@2` (a mesma que `figures`
24
+ * consome) como função pura e injetável.
25
+ *
26
+ * Reason: `figures` decide o fallback UMA vez, no import, a partir do ambiente
27
+ * real — para orçarmos os glyphs "common" pelo MESMO critério (e testá-lo com
28
+ * ambientes simulados) precisamos da decisão exposta como função. Manter a
29
+ * lógica idêntica à da lib garante que os dois caminhos concordem.
30
+ *
31
+ * @param env - Ambiente do processo (ou um subconjunto controlado em testes).
32
+ * @param platform - Plataforma (`process.platform`); injetável para testes.
33
+ * @returns `true` quando o terminal deve renderizar glyphs Unicode.
34
+ * @example
35
+ * isUnicodeSupported({ WT_SESSION: '1' }, 'win32'); // true (Windows Terminal)
36
+ * isUnicodeSupported({}, 'win32'); // false (cmd.exe/PowerShell legado)
37
+ */
38
+ export function isUnicodeSupported(env = process.env, platform = process.platform) {
39
+ const { TERM, TERM_PROGRAM } = env;
40
+ if (platform !== 'win32') {
41
+ return TERM !== 'linux'; // console do kernel Linux não renderiza Unicode
42
+ }
43
+ return (Boolean(env.WT_SESSION) || // Windows Terminal
44
+ Boolean(env.TERMINUS_SUBLIME) || // Terminus (<0.2.27)
45
+ env.ConEmuTask === '{cmd::Cmder}' || // ConEmu e cmder
46
+ TERM_PROGRAM === 'Terminus-Sublime' ||
47
+ TERM_PROGRAM === 'vscode' ||
48
+ TERM === 'xterm-256color' ||
49
+ TERM === 'alacritty' ||
50
+ TERM === 'rxvt-unicode' ||
51
+ TERM === 'rxvt-unicode-256color' ||
52
+ env.TERMINAL_EMULATOR === 'JetBrains-JediTerm');
53
+ }
54
+ /** Glyphs Unicode — terminais modernos (Windows Terminal, iTerm, etc.). */
55
+ export const UNICODE_GLYPHS = {
56
+ ellipsis: '…',
57
+ middleDot: '·',
58
+ emptyPlaceholder: '—',
59
+ arrowUp: '↑',
60
+ arrowDown: '↓',
61
+ maskBullet: '•',
62
+ spinnerFrames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
63
+ };
64
+ /** Fallback ASCII puro — terminal legado do Windows (sem mojibake). */
65
+ export const ASCII_GLYPHS = {
66
+ ellipsis: '...',
67
+ middleDot: '|',
68
+ emptyPlaceholder: '-',
69
+ arrowUp: '^',
70
+ arrowDown: 'v',
71
+ maskBullet: '*',
72
+ spinnerFrames: ['|', '/', '-', '\\'],
73
+ };
74
+ /**
75
+ * Escolhe o conjunto de glyphs conforme o suporte a Unicode.
76
+ *
77
+ * @param unicode - `true` para glyphs Unicode, `false` para o fallback ASCII.
78
+ * @returns O conjunto de glyphs correspondente.
79
+ */
80
+ export function resolveGlyphs(unicode) {
81
+ return unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
82
+ }
83
+ /** Glyphs resolvidos para o ambiente atual — importado pelas telas/utilitários. */
84
+ export const glyphs = resolveGlyphs(isUnicodeSupported());
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Erro de rejeição de `guard()` quando o re-login em andamento é ABANDONADO
3
+ * (Esc na tela de onboarding, ver `../app.tsx`) em vez de concluído — fix do
4
+ * review do PR #119.
5
+ *
6
+ * @remarks Telas de dados que chamam `guard()` devem tratar esta rejeição
7
+ * como uma operação CANCELADA pelo usuário (volte a um estado neutro —
8
+ * ex.: `status: 'idle'` — igual a antes da chamada), e não como uma falha
9
+ * inesperada (nada de mensagem de erro em vermelho ou crash): o usuário
10
+ * escolheu conscientemente não reautenticar.
11
+ */
12
+ export declare class ReAuthAbortedError extends Error {
13
+ constructor(message?: string);
14
+ }
15
+ /** Valor retornado por {@link useAuthGuard}. */
16
+ export interface UseAuthGuardResult {
17
+ /**
18
+ * Envolve uma operação autenticada do core.
19
+ *
20
+ * Devolve o mesmo resultado (ou rejeição) da operação em condições normais.
21
+ * Em caso de `RedmineAuthError`, dispara o fluxo de re-auth descrito no
22
+ * módulo e só resolve depois que o usuário loga de novo e a operação é
23
+ * reexecutada — chamadoras que precisam de um `AbortController`/cleanup
24
+ * (ex.: `useEffect`) continuam funcionando normalmente: a promise
25
+ * devolvida é só ignorada se o componente desmontar antes dela resolver.
26
+ *
27
+ * Se o re-login for abandonado (Esc, ver `../app.tsx`), a `Promise`
28
+ * rejeita com {@link ReAuthAbortedError} — trate como cancelamento, não
29
+ * como erro (ver o remark da classe).
30
+ *
31
+ * @param operation - A chamada ao core a proteger (ex.: `fetchIssueBundle`).
32
+ */
33
+ guard<T>(operation: () => Promise<T>): Promise<T>;
34
+ }
35
+ /**
36
+ * Hook público de re-autenticação — ver o JSDoc do módulo para o contrato
37
+ * completo.
38
+ */
39
+ export declare function useAuthGuard(): UseAuthGuardResult;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Guarda central de re-autenticação (M2-13, issue #36).
3
+ *
4
+ * QUALQUER tela de dados (#29+) que chame o core autenticado deve envolver
5
+ * essa chamada com `guard()`. Se a operação rejeitar com `RedmineAuthError`
6
+ * (401 — a api_key expirou ou foi revogada no servidor), o guard:
7
+ *
8
+ * 1. guarda a tela ATUAL (`useNavigation().current`) como origem e a própria
9
+ * chamada como callback de retry;
10
+ * 2. empurra `onboarding-login` em modo re-auth
11
+ * (`OnboardingContext.beginReAuth`, `../screens/onboarding/onboarding-context.tsx`),
12
+ * reaproveitando o fluxo de onboarding já existente para o usuário logar
13
+ * de novo — sem repetir `onboarding-url`/`onboarding-mode`, já que a
14
+ * instância já é conhecida;
15
+ * 3. quando o re-login resolve com sucesso, `validating.tsx`/`api-key.tsx`
16
+ * (`../screens/onboarding/`) chamam `OnboardingContext.resolveReAuth()`,
17
+ * que dispara o `retry` guardado aqui — SÓ NESSE MOMENTO a `Promise`
18
+ * devolvida por `guard()` resolve, com o resultado da nova tentativa — e a
19
+ * navegação volta à tela de origem (`useNavigation().popTo`, chamado pelas
20
+ * mesmas telas antes de `resolveReAuth()`).
21
+ *
22
+ * Se o re-login falhar (senha errada, rede fora do ar), a tela de login
23
+ * segue seu fluxo normal (mensagem de erro, tentar de novo) — o `reAuth`
24
+ * guardado no contexto permanece intacto (a origem não se perde) até uma
25
+ * tentativa ter sucesso.
26
+ *
27
+ * A credencial antiga é sobrescrita normalmente pela cascata de persistência
28
+ * (`createCredentialCascade().set()`, já chamada por
29
+ * `./use-onboarding-callbacks.ts` em todo login bem-sucedido, re-auth ou não)
30
+ * — nenhum tratamento especial é necessário aqui.
31
+ *
32
+ * Fix do review do PR #119 — dois problemas cobertos aqui:
33
+ *
34
+ * 1. Abandono do re-login (Esc): antes, a `Promise` de `guard()` ficava
35
+ * pendurada para sempre se o usuário apertasse Esc na tela de login em
36
+ * vez de completar o re-auth. Agora o atalho global de Esc
37
+ * (`../../app.tsx`), ao detectar que está numa tela `onboarding-*` com um
38
+ * `reAuth` ativo, chama `OnboardingContext.abortReAuth()` — que rejeita
39
+ * a `Promise` com {@link ReAuthAbortedError}. Chamadoras de `guard()`
40
+ * (telas de dados) devem tratar essa rejeição como uma operação
41
+ * CANCELADA (estado neutro — "usuário desistiu de logar de novo"), não
42
+ * como uma falha a ser exibida como erro/crash.
43
+ * 2. 401 concorrentes: antes, um segundo `guard()` falhando com 401 enquanto
44
+ * o primeiro já tinha iniciado o re-auth SOBRESCREVIA o `reAuth` no
45
+ * contexto — o primeiro retry/reject ficava órfão. Agora
46
+ * `OnboardingContext.beginReAuth()` acumula uma lista de pendências; uma
47
+ * única passagem de login bem-sucedida (`resolveReAuth()`) resolve TODOS
48
+ * os `guard()` pendentes.
49
+ *
50
+ * @example
51
+ * // Tela de dados futura (#29+, ex.: `screens/home.tsx`):
52
+ * import { useAuthGuard } from '../hooks/use-auth-guard.js';
53
+ *
54
+ * function HomeScreen() {
55
+ * const { guard } = useAuthGuard();
56
+ * const [bundle, setBundle] = useState<IssueBundleResult>();
57
+ *
58
+ * useEffect(() => {
59
+ * let cancelled = false;
60
+ * guard(() => fetchIssueBundleViaCore(issueId)).then((result) => {
61
+ * if (!cancelled) setBundle(result);
62
+ * });
63
+ * return () => {
64
+ * cancelled = true;
65
+ * };
66
+ * }, [issueId]);
67
+ * // ...
68
+ * }
69
+ */
70
+ import { useCallback, useRef } from 'react';
71
+ import { RedmineAuthError } from '../../../index.js';
72
+ import { useNavigation } from '../navigation.js';
73
+ import { useOnboarding } from '../screens/onboarding/onboarding-context.js';
74
+ /**
75
+ * Erro de rejeição de `guard()` quando o re-login em andamento é ABANDONADO
76
+ * (Esc na tela de onboarding, ver `../app.tsx`) em vez de concluído — fix do
77
+ * review do PR #119.
78
+ *
79
+ * @remarks Telas de dados que chamam `guard()` devem tratar esta rejeição
80
+ * como uma operação CANCELADA pelo usuário (volte a um estado neutro —
81
+ * ex.: `status: 'idle'` — igual a antes da chamada), e não como uma falha
82
+ * inesperada (nada de mensagem de erro em vermelho ou crash): o usuário
83
+ * escolheu conscientemente não reautenticar.
84
+ */
85
+ export class ReAuthAbortedError extends Error {
86
+ constructor(message = 'Re-autenticação cancelada pelo usuário (Esc).') {
87
+ super(message);
88
+ this.name = 'ReAuthAbortedError';
89
+ }
90
+ }
91
+ /**
92
+ * Hook público de re-autenticação — ver o JSDoc do módulo para o contrato
93
+ * completo.
94
+ */
95
+ export function useAuthGuard() {
96
+ const { current, push } = useNavigation();
97
+ const { beginReAuth } = useOnboarding();
98
+ // Refs (não deps do useCallback): `guard` precisa ficar com identidade
99
+ // ESTÁVEL (padrão do repo) mesmo com `current`/`push`/`beginReAuth` mudando
100
+ // entre renders — o valor lido é sempre o mais recente via ref.
101
+ const currentRef = useRef(current);
102
+ currentRef.current = current;
103
+ const pushRef = useRef(push);
104
+ pushRef.current = push;
105
+ const beginReAuthRef = useRef(beginReAuth);
106
+ beginReAuthRef.current = beginReAuth;
107
+ const guard = useCallback(function guardOperation(operation,
108
+ // A origem é capturada UMA VEZ, na primeira falha — e reaproveitada em
109
+ // retries subsequentes (`falha do re-login não perde a origem`, DoD
110
+ // #36), mesmo que a navegação já tenha mudado quando o retry acontece.
111
+ origin = currentRef.current) {
112
+ return operation().catch((cause) => {
113
+ if (!(cause instanceof RedmineAuthError)) {
114
+ throw cause;
115
+ }
116
+ return new Promise((resolve, reject) => {
117
+ // Fix do review #119: `beginReAuth` devolve `true` só na PRIMEIRA
118
+ // vez que este ciclo de re-auth é ativado — 401 concorrentes
119
+ // (segunda chamada enquanto a primeira já empilhou o login) só
120
+ // adicionam a pendência à lista existente, sem empilhar
121
+ // `onboarding-login` de novo nem perder o retry/reject anterior.
122
+ const activated = beginReAuthRef.current(origin, {
123
+ retry: () => {
124
+ guardOperation(operation, origin).then(resolve, reject);
125
+ },
126
+ reject,
127
+ });
128
+ if (activated) {
129
+ pushRef.current('onboarding-login');
130
+ }
131
+ });
132
+ });
133
+ }, []);
134
+ return { guard };
135
+ }
@@ -0,0 +1,64 @@
1
+ import { describeCredentialSource, type CredentialSourceKind } from '../../../index.js';
2
+ /** Resultado do teste leve de conectividade. */
3
+ export type ConnectivityStatus = 'checking' | 'ok' | 'error' | 'skipped';
4
+ /** Snapshot completo do painel de diagnóstico exibido por `doctor.tsx`. */
5
+ export interface DoctorStatus {
6
+ /** Versão do runtime Node (`process.version`, ou injetada em teste). */
7
+ nodeVersion: string;
8
+ /** Nome do produto (via core). */
9
+ toolName: string;
10
+ /** Versão do produto (via core). */
11
+ toolVersion: string;
12
+ /** Instância configurada (`REDMINE_URL`), ou `undefined` se ausente. */
13
+ instanceUrl: string | undefined;
14
+ /**
15
+ * Fonte da credencial em uso na cascata — NUNCA a api_key em si.
16
+ * `'checking'` enquanto a consulta assíncrona ainda não resolveu.
17
+ */
18
+ credentialMethod: CredentialSourceKind | 'checking';
19
+ /** Resultado do teste leve de conectividade (ver {@link ConnectivityStatus}). */
20
+ connectivity: ConnectivityStatus;
21
+ /** Detalhe do erro de conectividade, se houver — sempre seguro (sem segredos). */
22
+ connectivityDetail: string | undefined;
23
+ }
24
+ /** Resultado devolvido por um {@link ConnectivityChecker}. */
25
+ export interface ConnectivityCheckResult {
26
+ /** `true` se a instância respondeu com sucesso dentro do timeout. */
27
+ ok: boolean;
28
+ /** Detalhe legível da falha (status HTTP ou motivo da exceção). Ausente quando `ok`. */
29
+ detail?: string;
30
+ }
31
+ /**
32
+ * Executa a checagem de conectividade — injetável para testes (o default de
33
+ * produção usa `fetch` global com timeout via `AbortController`).
34
+ */
35
+ export type ConnectivityChecker = (instanceUrl: string, timeoutMs: number) => Promise<ConnectivityCheckResult>;
36
+ /** Dependências injetáveis do hook — todas opcionais, com defaults de produção. */
37
+ export interface UseDoctorStatusOptions {
38
+ /** Ambiente consultado para `REDMINE_URL`; default `process.env`. */
39
+ env?: NodeJS.ProcessEnv;
40
+ /** Versão do Node exibida; default `process.version`. */
41
+ nodeVersion?: string;
42
+ /** Resolve a fonte da credencial; default `describeCredentialSource` do core. */
43
+ describeCredentialSource?: typeof describeCredentialSource;
44
+ /** Executa a checagem de conectividade; default via `fetch` global. */
45
+ checkConnectivity?: ConnectivityChecker;
46
+ /** Timeout (ms) da checagem de conectividade; default {@link DEFAULT_CONNECTIVITY_TIMEOUT_MS}. */
47
+ timeoutMs?: number;
48
+ }
49
+ /** Timeout default do teste de conectividade — curto o bastante para não travar a tela. */
50
+ export declare const DEFAULT_CONNECTIVITY_TIMEOUT_MS = 3000;
51
+ /**
52
+ * Reúne o status do doctor: node/tool/instância (síncronos, disponíveis já no
53
+ * primeiro render) + credencial e conectividade (assíncronos, resolvidos via
54
+ * `useEffect` quando há uma instância configurada).
55
+ *
56
+ * @param options - Dependências injetáveis. Ver {@link UseDoctorStatusOptions}.
57
+ * @returns O {@link DoctorStatus} atual — `credentialMethod`/`connectivity`
58
+ * começam em `'checking'`/`'skipped'` e são atualizados quando a checagem
59
+ * assíncrona resolve (ou `'skipped'` direto, se não há instância configurada).
60
+ *
61
+ * @example
62
+ * const status = useDoctorStatus(); // deps de produção (process.env, fetch real)
63
+ */
64
+ export declare function useDoctorStatus(options?: UseDoctorStatusOptions): DoctorStatus;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Lógica de status do painel `doctor` (M2-12, #35): reúne versão do Node, da
3
+ * ferramenta, instância configurada, método de credencial em uso e status de
4
+ * conectividade. Extraída para um hook porque a credencial e a conectividade
5
+ * são assíncronas (cascata de credenciais, rede) e precisam ser testáveis com
6
+ * dependências injetadas — nenhum teste deste arquivo toca keychain ou rede
7
+ * reais.
8
+ *
9
+ * Fronteira do core (ADR-005): a única coisa importada daqui é
10
+ * `describeCredentialSource` + `TOOL_NAME`/`TOOL_VERSION` via `../../../index.js`
11
+ * — a checagem de conectividade usa `fetch` global diretamente (mesmo padrão
12
+ * de baixo nível de `client/http.ts`/`config/login.ts`), porque o doctor testa
13
+ * apenas ALCANÇABILIDADE (GET leve, sem autenticação) — não uma chamada de
14
+ * API do core, então não há nada do core a reexportar para isso.
15
+ *
16
+ * Sem vazamento de segredos: a api_key nunca entra neste módulo — a fonte da
17
+ * credencial é relatada pelo NOME (`'keyring' | 'file' | 'env' | 'none'`), e a
18
+ * checagem de conectividade é uma requisição não autenticada.
19
+ */
20
+ import { useEffect, useState } from 'react';
21
+ import { describeCredentialSource, TOOL_NAME, TOOL_VERSION } from '../../../index.js';
22
+ /** Timeout default do teste de conectividade — curto o bastante para não travar a tela. */
23
+ export const DEFAULT_CONNECTIVITY_TIMEOUT_MS = 3000;
24
+ /**
25
+ * Checagem default de conectividade: GET leve (sem autenticação) à instância,
26
+ * com timeout via `AbortController`. Não autenticado de propósito — o doctor
27
+ * testa alcançabilidade de rede, não valida a credencial (isso já é reportado
28
+ * separadamente por `credentialMethod`), então a api_key nunca participa
29
+ * desta requisição.
30
+ */
31
+ const defaultCheckConnectivity = async (instanceUrl, timeoutMs) => {
32
+ const controller = new AbortController();
33
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
34
+ try {
35
+ const response = await fetch(instanceUrl, { method: 'GET', signal: controller.signal });
36
+ if (!response.ok) {
37
+ return { ok: false, detail: `HTTP ${response.status}` };
38
+ }
39
+ return { ok: true };
40
+ }
41
+ catch (cause) {
42
+ const reason = cause instanceof Error ? cause.message : String(cause);
43
+ return { ok: false, detail: reason };
44
+ }
45
+ finally {
46
+ clearTimeout(timer);
47
+ }
48
+ };
49
+ /** Lê `REDMINE_URL` do ambiente, tratando string vazia como ausente. */
50
+ function instanceFromEnv(env) {
51
+ const value = env.REDMINE_URL;
52
+ return value !== undefined && value.length > 0 ? value : undefined;
53
+ }
54
+ /**
55
+ * Reúne o status do doctor: node/tool/instância (síncronos, disponíveis já no
56
+ * primeiro render) + credencial e conectividade (assíncronos, resolvidos via
57
+ * `useEffect` quando há uma instância configurada).
58
+ *
59
+ * @param options - Dependências injetáveis. Ver {@link UseDoctorStatusOptions}.
60
+ * @returns O {@link DoctorStatus} atual — `credentialMethod`/`connectivity`
61
+ * começam em `'checking'`/`'skipped'` e são atualizados quando a checagem
62
+ * assíncrona resolve (ou `'skipped'` direto, se não há instância configurada).
63
+ *
64
+ * @example
65
+ * const status = useDoctorStatus(); // deps de produção (process.env, fetch real)
66
+ */
67
+ export function useDoctorStatus(options = {}) {
68
+ const env = options.env ?? process.env;
69
+ const nodeVersion = options.nodeVersion ?? process.version;
70
+ const resolveSource = options.describeCredentialSource ?? describeCredentialSource;
71
+ const checkConnectivity = options.checkConnectivity ?? defaultCheckConnectivity;
72
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CONNECTIVITY_TIMEOUT_MS;
73
+ const instanceUrl = instanceFromEnv(env);
74
+ const [credentialMethod, setCredentialMethod] = useState(instanceUrl === undefined ? 'none' : 'checking');
75
+ const [connectivity, setConnectivity] = useState(instanceUrl === undefined ? 'skipped' : 'checking');
76
+ const [connectivityDetail, setConnectivityDetail] = useState(undefined);
77
+ useEffect(() => {
78
+ if (instanceUrl === undefined) {
79
+ setCredentialMethod('none');
80
+ setConnectivity('skipped');
81
+ setConnectivityDetail(undefined);
82
+ return;
83
+ }
84
+ let cancelled = false;
85
+ setCredentialMethod('checking');
86
+ setConnectivity('checking');
87
+ setConnectivityDetail(undefined);
88
+ resolveSource(instanceUrl, { env }).then((source) => {
89
+ if (!cancelled)
90
+ setCredentialMethod(source);
91
+ }, () => {
92
+ if (!cancelled)
93
+ setCredentialMethod('none');
94
+ });
95
+ checkConnectivity(instanceUrl, timeoutMs).then((result) => {
96
+ if (cancelled)
97
+ return;
98
+ setConnectivity(result.ok ? 'ok' : 'error');
99
+ setConnectivityDetail(result.detail);
100
+ }, (cause) => {
101
+ if (cancelled)
102
+ return;
103
+ setConnectivity('error');
104
+ setConnectivityDetail(cause instanceof Error ? cause.message : String(cause));
105
+ });
106
+ return () => {
107
+ cancelled = true;
108
+ };
109
+ // Reason: `env`/`resolveSource`/`checkConnectivity` são estáveis entre
110
+ // renders quando as deps de produção são usadas (defaults do módulo, ou
111
+ // `process.env`) — o efeito só precisa refazer a consulta quando a
112
+ // instância configurada muda.
113
+ }, [instanceUrl]);
114
+ return {
115
+ nodeVersion,
116
+ toolName: TOOL_NAME,
117
+ toolVersion: TOOL_VERSION,
118
+ instanceUrl,
119
+ credentialMethod,
120
+ connectivity,
121
+ connectivityDetail,
122
+ };
123
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Consome o interceptor ativo, se houver — chamado pelo roteador (`../app.tsx`)
3
+ * ANTES de aplicar o `pop()` padrão do Esc global.
4
+ *
5
+ * @returns `true` se um interceptor tratou o Esc (o chamador não deve fazer
6
+ * `pop()` nesse caso); `false` se nenhum interceptor está registrado (o
7
+ * chamador segue com o comportamento padrão).
8
+ */
9
+ export declare function consumeEscapeInterceptor(): boolean;
10
+ /** Somente para testes: garante um estado limpo entre casos de teste. */
11
+ export declare function resetEscapeInterceptor(): void;
12
+ /**
13
+ * Registra `onEscape` como o interceptor ativo enquanto `active` for `true`;
14
+ * desregistra automaticamente quando `active` vira `false` ou o componente
15
+ * desmonta (limpeza do `useEffect`).
16
+ *
17
+ * @param active - Só registra o interceptor enquanto `true` (ex.: campo de
18
+ * busca da home aberto).
19
+ * @param onEscape - Chamado quando `Esc` é pressionado e este interceptor
20
+ * está ativo. Identidade pode mudar entre renders (lido via `ref`).
21
+ *
22
+ * @example
23
+ * useEscapeInterceptor(isSearching, closeSearch);
24
+ */
25
+ export declare function useEscapeInterceptor(active: boolean, onEscape: () => void): void;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Interceptação pontual do Esc global (M2-07, issue #30).
3
+ *
4
+ * O roteador (`../app.tsx`) trata `Esc` globalmente como `pop()` da pilha de
5
+ * navegação (volta para a tela anterior). A busca inline da home
6
+ * (`../screens/home.tsx`) precisa que `Esc`, enquanto o campo de busca está
7
+ * ativo, feche a busca e restaure a lista original — em vez de desempilhar a
8
+ * home e voltar para a tela de onde ela veio. Sem esse desvio, o usuário
9
+ * sairia da home sem querer só por tentar limpar o texto da busca.
10
+ *
11
+ * Implementado como uma única referência mutável em nível de módulo, não um
12
+ * Context/Provider: a TUI só sustenta UMA tela ativa por vez (arquitetura de
13
+ * pilha, `../navigation.tsx`), então nunca há mais de um interceptor
14
+ * concorrente de fato — um Provider/Context inteiro seria complexidade sem
15
+ * benefício para esse caso. O mecanismo é genérico o bastante para qualquer
16
+ * tela futura que precise do mesmo desvio pontual do Esc global.
17
+ */
18
+ import { useEffect, useRef } from 'react';
19
+ /** Interceptor ativo no momento, ou `undefined` se nenhuma tela registrou um. */
20
+ let activeInterceptor;
21
+ /**
22
+ * Consome o interceptor ativo, se houver — chamado pelo roteador (`../app.tsx`)
23
+ * ANTES de aplicar o `pop()` padrão do Esc global.
24
+ *
25
+ * @returns `true` se um interceptor tratou o Esc (o chamador não deve fazer
26
+ * `pop()` nesse caso); `false` se nenhum interceptor está registrado (o
27
+ * chamador segue com o comportamento padrão).
28
+ */
29
+ export function consumeEscapeInterceptor() {
30
+ if (activeInterceptor === undefined) {
31
+ return false;
32
+ }
33
+ activeInterceptor();
34
+ return true;
35
+ }
36
+ /** Somente para testes: garante um estado limpo entre casos de teste. */
37
+ export function resetEscapeInterceptor() {
38
+ activeInterceptor = undefined;
39
+ }
40
+ /**
41
+ * Registra `onEscape` como o interceptor ativo enquanto `active` for `true`;
42
+ * desregistra automaticamente quando `active` vira `false` ou o componente
43
+ * desmonta (limpeza do `useEffect`).
44
+ *
45
+ * @param active - Só registra o interceptor enquanto `true` (ex.: campo de
46
+ * busca da home aberto).
47
+ * @param onEscape - Chamado quando `Esc` é pressionado e este interceptor
48
+ * está ativo. Identidade pode mudar entre renders (lido via `ref`).
49
+ *
50
+ * @example
51
+ * useEscapeInterceptor(isSearching, closeSearch);
52
+ */
53
+ export function useEscapeInterceptor(active, onEscape) {
54
+ const onEscapeRef = useRef(onEscape);
55
+ onEscapeRef.current = onEscape;
56
+ useEffect(() => {
57
+ if (!active) {
58
+ return;
59
+ }
60
+ activeInterceptor = () => onEscapeRef.current();
61
+ return () => {
62
+ activeInterceptor = undefined;
63
+ };
64
+ }, [active]);
65
+ }