@payconductor/houndsman 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payconductor/houndsman",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Orquestrador de revisão de Pull Requests: classifica o diff, despacha watchdogs especializados por domínio e consolida o relatório.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,9 +26,15 @@ export class ServerCommand implements Command {
26
26
  const parser = ArgParser.from(args);
27
27
  const port = Number(parser.value("--port")) || 7331;
28
28
  const host = parser.value("--host") ?? "127.0.0.1";
29
+ const editorPreference = parser.value("--editor");
29
30
 
30
31
  const { startServer } = await import("../../server");
31
- const { url } = startServer({ config: engine.config, port, host });
32
+ const { url } = startServer({
33
+ config: engine.config,
34
+ port,
35
+ host,
36
+ editorPreference,
37
+ });
32
38
  context.out(`[houndsman] servidor em ${url}`);
33
39
  context.out("[houndsman] Ctrl+C para encerrar.");
34
40
  if (parser.has("--open")) openBrowser(url);
package/src/cli/help.ts CHANGED
@@ -8,7 +8,7 @@ Comandos:
8
8
  hunt Revisão nativa com IA (harness próprio): prepare + agentes + finalize
9
9
  findings <sub> Triagem/correção dos findings (list · show · set · done · reset · sync · summary · batch · verify · submit · path)
10
10
  status [ping] Mostra o status da run (ping renova o heartbeat da execução)
11
- server Sobe a UI local de triagem (--port, --host, --open)
11
+ server Sobe a UI local de triagem (--port, --host, --open, --editor)
12
12
  setup Cria a configuração inicial do projeto (config + skills)
13
13
  update Sincroniza os arquivos auto-gerados (.houndsman/core)
14
14
  doctor Valida a configuração, o ambiente e a credencial Jev
@@ -657,18 +657,23 @@ function splitList(raw: string | undefined): string[] {
657
657
  .filter(Boolean);
658
658
  }
659
659
 
660
- /** Seleção do envio a partir das flags (`--severity`, `--exec`, `--include-sent`). */
660
+ /** Seleção do envio a partir das flags (`--severity`, `--decision`, `--exec`). */
661
661
  function submitSelection(parsed: ParsedArgs): SubmitSelection {
662
662
  const severities = splitList(parsed.values["--severity"]).filter(
663
663
  (value): value is Severity =>
664
664
  (SEVERITY_ORDER as readonly string[]).includes(value),
665
665
  );
666
+ const decisions = splitList(parsed.values["--decision"]).filter(
667
+ (value): value is FindingDecision =>
668
+ (FINDING_DECISIONS as readonly string[]).includes(value),
669
+ );
666
670
  const execStates = splitList(parsed.values["--exec"]).filter(
667
671
  (value): value is FindingExecState =>
668
672
  (FINDING_EXEC_STATES as readonly string[]).includes(value),
669
673
  );
670
674
  return {
671
675
  severities: severities.length > 0 ? severities : undefined,
676
+ decisions: decisions.length > 0 ? decisions : undefined,
672
677
  execStates: execStates.length > 0 ? execStates : undefined,
673
678
  includeSent: parsed.bools.has("--include-sent"),
674
679
  };
@@ -29,7 +29,7 @@ export const DEFAULT_AST_MAX_IMPACT_FILES = 200;
29
29
  export const DEFAULT_AI_PROVIDER = "deepseek" as const;
30
30
 
31
31
  /** Modelo default da camada de IA. */
32
- export const DEFAULT_AI_MODEL = "deepseek-v4.1-flash";
32
+ export const DEFAULT_AI_MODEL = "deepseek-v4-pro";
33
33
 
34
34
  /** Concorrência default das chamadas de IA. */
35
35
  export const DEFAULT_AI_CONCURRENCY = 2;
@@ -16,20 +16,32 @@ export type GitResult = {
16
16
  /** Executa `git` e devolve o resultado cru. Injetável nos testes. */
17
17
  export type GitRunner = (args: string[]) => GitResult;
18
18
 
19
- /** Runner padrão: `git -c core.quotepath=false <args>` a partir do cwd. */
20
- export const defaultGitRunner: GitRunner = (args) => {
21
- const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
22
- stdout: "pipe",
23
- stderr: "pipe",
24
- });
25
- const code = result.exitCode ?? 1;
26
- return {
27
- ok: code === 0,
28
- code,
29
- stdout: new TextDecoder().decode(result.stdout),
30
- stderr: new TextDecoder().decode(result.stderr),
19
+ /**
20
+ * Cria um runner do git com diretório de trabalho fixo.
21
+ *
22
+ * O servidor roda a partir da raiz do repositório, mas o checkout pode ser
23
+ * disparado de outra sessão — o `cwd` garante que os comandos atinjam o repo
24
+ * certo. Sem `cwd`, herda o diretório do processo (default).
25
+ */
26
+ export function createGitRunner(cwd?: string): GitRunner {
27
+ return (args) => {
28
+ const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
29
+ cwd,
30
+ stdout: "pipe",
31
+ stderr: "pipe",
32
+ });
33
+ const code = result.exitCode ?? 1;
34
+ return {
35
+ ok: code === 0,
36
+ code,
37
+ stdout: new TextDecoder().decode(result.stdout),
38
+ stderr: new TextDecoder().decode(result.stderr),
39
+ };
31
40
  };
32
- };
41
+ }
42
+
43
+ /** Runner padrão: `git -c core.quotepath=false <args>` a partir do cwd. */
44
+ export const defaultGitRunner: GitRunner = createGitRunner();
33
45
 
34
46
  /** Executa e devolve o stdout; lança com o stderr quando o git falha. */
35
47
  export function runGit(args: string[], runner: GitRunner = defaultGitRunner): string {
@@ -24,7 +24,12 @@ import {
24
24
  type FindingsStateFile,
25
25
  } from "./findings-state";
26
26
  import { systemClock } from "./services/clock";
27
- import { defaultGhRunner, type GhResult, type GhRunner } from "./services/gh-client";
27
+ import {
28
+ defaultGhRunner,
29
+ describeGhError,
30
+ type GhResult,
31
+ type GhRunner,
32
+ } from "./services/gh-client";
28
33
  import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity } from "./types";
29
34
 
30
35
  export { type GhResult, type GhRunner, defaultGhRunner };
@@ -54,12 +59,7 @@ function repoFromUrl(url: string | undefined): string | null {
54
59
 
55
60
  /** Mensagem curta para um erro do `gh`. */
56
61
  function shortError(result: GhResult): string {
57
- if (result.code === 127 || /not found|ENOENT/i.test(result.stderr)) {
58
- return "gh não instalado.";
59
- }
60
- const last = result.stderr.split("\n").filter(Boolean).pop() ?? "";
61
- if (/auth|login|logged/i.test(last)) return `gh sem autenticação (${last}).`;
62
- return last || `gh saiu com código ${result.code}.`;
62
+ return describeGhError(result);
63
63
  }
64
64
 
65
65
  /**
@@ -118,6 +118,8 @@ export type SubmitCandidate = {
118
118
  file: string;
119
119
  line: number | null;
120
120
  severity: Severity;
121
+ /** Decisão da triagem (`execute` = "a executar"/fila de correção). */
122
+ decision: FindingDecision;
121
123
  execState: FindingExecState;
122
124
  rule: string;
123
125
  message: string;
@@ -159,6 +161,7 @@ export function selectSubmitCandidates(
159
161
  file: finding.file,
160
162
  line: finding.line,
161
163
  severity: finding.severity,
164
+ decision: finding.decision,
162
165
  execState: finding.execState,
163
166
  rule: finding.rule,
164
167
  message: finding.message,
@@ -10,29 +10,45 @@ export type GhResult = { ok: boolean; code: number; stdout: string; stderr: stri
10
10
  /** Executa o `gh` e devolve o resultado cru (sem lançar). */
11
11
  export type GhRunner = (args: string[]) => GhResult;
12
12
 
13
- /** Runner padrão: `gh` a partir do cwd; ausência do binário → `ok:false`. */
14
- export const defaultGhRunner: GhRunner = (args) => {
15
- try {
16
- const result = Bun.spawnSync(["gh", ...args], {
17
- stdout: "pipe",
18
- stderr: "pipe",
19
- });
20
- const code = result.exitCode ?? 1;
21
- return {
22
- ok: code === 0,
23
- code,
24
- stdout: new TextDecoder().decode(result.stdout).trim(),
25
- stderr: new TextDecoder().decode(result.stderr).trim(),
26
- };
27
- } catch (error) {
28
- return {
29
- ok: false,
30
- code: 127,
31
- stdout: "",
32
- stderr: error instanceof Error ? error.message : String(error),
33
- };
13
+ /** Mensagem curta para um erro do `gh` (sem expor o stderr inteiro). */
14
+ export function describeGhError(result: GhResult): string {
15
+ if (result.code === 127 || /not found|ENOENT/i.test(result.stderr)) {
16
+ return "gh não instalado.";
34
17
  }
35
- };
18
+ const last = result.stderr.split("\n").filter(Boolean).pop() ?? "";
19
+ if (/auth|login|logged/i.test(last)) return `gh sem autenticação (${last}).`;
20
+ return last || `gh saiu com código ${result.code}.`;
21
+ }
22
+
23
+ /** Cria um runner do `gh` com diretório de trabalho fixo. */
24
+ export function createGhRunner(cwd?: string): GhRunner {
25
+ return (args) => {
26
+ try {
27
+ const result = Bun.spawnSync(["gh", ...args], {
28
+ cwd,
29
+ stdout: "pipe",
30
+ stderr: "pipe",
31
+ });
32
+ const code = result.exitCode ?? 1;
33
+ return {
34
+ ok: code === 0,
35
+ code,
36
+ stdout: new TextDecoder().decode(result.stdout).trim(),
37
+ stderr: new TextDecoder().decode(result.stderr).trim(),
38
+ };
39
+ } catch (error) {
40
+ return {
41
+ ok: false,
42
+ code: 127,
43
+ stdout: "",
44
+ stderr: error instanceof Error ? error.message : String(error),
45
+ };
46
+ }
47
+ };
48
+ }
49
+
50
+ /** Runner padrão: `gh` a partir do cwd; ausência do binário → `ok:false`. */
51
+ export const defaultGhRunner: GhRunner = createGhRunner();
36
52
 
37
53
  /** Superfície OO sobre o runner injetável. */
38
54
  export class GhClient {
@@ -7,18 +7,19 @@
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
8
8
  import { ArtifactsPanel } from "./ArtifactsPanel";
9
9
  import { AutoRefresh } from "./AutoRefresh";
10
+ import { CommentsPanel } from "./CommentsPanel";
10
11
  import { Filters, type FiltersState } from "./Filters";
11
12
  import { FindingsList } from "./FindingsList";
12
- import { FileText, Layers, ListIcon, Refresh, Send } from "./icons";
13
+ import { FileText, Layers, ListIcon, MessageSquare, Refresh, Send } from "./icons";
13
14
  import { ResumePanel } from "./ResumePanel";
14
- import { RunList } from "./RunList";
15
+ import { Sidebar } from "./Sidebar";
15
16
  import { SubmitModal } from "./SubmitModal";
16
17
  import { SummaryBar } from "./SummaryBar";
17
18
  import { api, computeSummary } from "./utils/api";
18
19
  import { AUTO_REFRESH_MS, TAB_CAPTIONS } from "./utils/constants";
19
- import type { Finding, FindingsResponse, RunSummary } from "./utils/types";
20
+ import type { EditorContext, Finding, FindingsResponse, RunSummary } from "./utils/types";
20
21
 
21
- type Tab = "resume" | "findings" | "artifacts";
22
+ type Tab = "resume" | "findings" | "artifacts" | "comments";
22
23
 
23
24
  const TABS: { id: Tab; label: string; hint: string }[] = [
24
25
  {
@@ -36,8 +37,20 @@ const TABS: { id: Tab; label: string; hint: string }[] = [
36
37
  label: "Artefatos",
37
38
  hint: "Arquivos brutos gerados pela revisão (manifesto, classificação, relatório…)",
38
39
  },
40
+ {
41
+ id: "comments",
42
+ label: "Comentários",
43
+ hint: "Comentários da PR: threads de revisão e comentários gerais (carregados ao abrir a aba)",
44
+ },
39
45
  ];
40
46
 
47
+ function TabIcon({ id }: { id: Tab }) {
48
+ if (id === "resume") return <FileText size={15} />;
49
+ if (id === "findings") return <ListIcon size={15} />;
50
+ if (id === "artifacts") return <Layers size={15} />;
51
+ return <MessageSquare size={15} />;
52
+ }
53
+
41
54
  export function App() {
42
55
  const [runs, setRuns] = useState<RunSummary[]>([]);
43
56
  const [slug, setSlug] = useState<string | null>(null);
@@ -54,6 +67,8 @@ export function App() {
54
67
  const [refreshing, setRefreshing] = useState(false);
55
68
  const [syncing, setSyncing] = useState(false);
56
69
  const [submitOpen, setSubmitOpen] = useState(false);
70
+ /** Editor detectado pelo servidor — base dos links de código. */
71
+ const [editor, setEditor] = useState<EditorContext>({ kind: "vscode" });
57
72
  /** Refs espelhando estado volátil — o polling não deve recriar o intervalo. */
58
73
  const lastSyncRef = useRef(lastSync);
59
74
  const slugRef = useRef<string | null>(null);
@@ -116,6 +131,14 @@ export function App() {
116
131
  reloadRuns();
117
132
  }, [reloadRuns]);
118
133
 
134
+ useEffect(() => {
135
+ api<EditorContext>("/api/editor")
136
+ .then(setEditor)
137
+ .catch(() => {
138
+ // fail-open: mantém o fallback VS Code.
139
+ });
140
+ }, []);
141
+
119
142
  useEffect(() => {
120
143
  if (slug) loadFindings(slug);
121
144
  }, [slug, loadFindings]);
@@ -194,7 +217,12 @@ export function App() {
194
217
 
195
218
  return (
196
219
  <div class="app">
197
- <RunList runs={runs} slug={slug} onSelect={setSlug} />
220
+ <Sidebar
221
+ runs={runs}
222
+ slug={slug}
223
+ onSelect={setSlug}
224
+ onRefreshRuns={() => void reloadRuns()}
225
+ />
198
226
  <main class="main">
199
227
  <header class="topbar">
200
228
  <div class="segmented">
@@ -206,13 +234,7 @@ export function App() {
206
234
  class={`seg-btn${tab === item.id ? " active" : ""}`}
207
235
  onClick={() => setTab(item.id)}
208
236
  >
209
- {item.id === "resume" ? (
210
- <FileText size={15} />
211
- ) : item.id === "findings" ? (
212
- <ListIcon size={15} />
213
- ) : (
214
- <Layers size={15} />
215
- )}
237
+ <TabIcon id={item.id} />
216
238
  {item.label}
217
239
  </button>
218
240
  ))}
@@ -278,11 +300,15 @@ export function App() {
278
300
  <FindingsList
279
301
  slug={slug}
280
302
  findings={findings}
303
+ editor={editor}
281
304
  onUpdated={onUpdated}
282
305
  />
283
306
  </>
284
307
  )}
285
308
  {slug && tab === "artifacts" && <ArtifactsPanel slug={slug} />}
309
+ {slug && tab === "comments" && (
310
+ <CommentsPanel slug={slug} editor={editor} />
311
+ )}
286
312
  {!slug && !error && <p class="empty">Nenhuma execução para triar.</p>}
287
313
  </div>
288
314
  {slug && (
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Aba "Comentários": threads de revisão e comentários gerais da PR da run.
3
+ *
4
+ * O componente só é montado quando a aba é acessada — o carregamento (e a
5
+ * chamada ao `gh`) acontece no mount, nunca no carregamento da página.
6
+ */
7
+
8
+ import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
9
+ import { Clock, CodeIcon, ExternalLink, GitHub, MessageSquare, Refresh } from "./icons";
10
+ import { MarkdownText } from "./MarkdownText";
11
+ import { api } from "./utils/api";
12
+ import { editorFileLink, editorLabel, formatRunTime } from "./utils/format";
13
+ import type {
14
+ EditorContext,
15
+ PrCommentsResponse,
16
+ PrCommentThread,
17
+ PrCommentView,
18
+ } from "./utils/types";
19
+
20
+ function CommentCard({
21
+ comment,
22
+ editor,
23
+ reply,
24
+ }: {
25
+ comment: PrCommentView;
26
+ editor: EditorContext;
27
+ reply: boolean;
28
+ }) {
29
+ const location = comment.path
30
+ ? comment.line
31
+ ? `${comment.path}:${comment.line}`
32
+ : comment.path
33
+ : null;
34
+ const href = comment.absFile
35
+ ? editorFileLink(comment.absFile, comment.line, editor)
36
+ : null;
37
+ return (
38
+ <article class={`pr-comment${reply ? " reply" : ""}`}>
39
+ <header class="pr-comment-head">
40
+ <span class="pr-comment-author">{comment.author}</span>
41
+ {comment.authorType === "Bot" && <span class="tag">bot</span>}
42
+ <span class="run-when">
43
+ <Clock size={12} />
44
+ {formatRunTime(comment.createdAt)}
45
+ </span>
46
+ <span class="spacer" />
47
+ {comment.url && (
48
+ <a
49
+ class="pr-comment-link"
50
+ href={comment.url}
51
+ target="_blank"
52
+ rel="noreferrer"
53
+ title="Abrir o comentário no GitHub"
54
+ >
55
+ <GitHub size={12} /> GitHub
56
+ </a>
57
+ )}
58
+ </header>
59
+ <div class="pr-comment-body">
60
+ <MarkdownText text={comment.body} />
61
+ </div>
62
+ {location &&
63
+ (href ? (
64
+ <a
65
+ class="loc"
66
+ href={href}
67
+ title={`Abrir ${location} no ${editorLabel(editor)}`}
68
+ >
69
+ <CodeIcon size={13} />
70
+ {location}
71
+ <ExternalLink size={12} class="ext" />
72
+ </a>
73
+ ) : (
74
+ <code class="loc static">
75
+ <CodeIcon size={13} />
76
+ {location}
77
+ </code>
78
+ ))}
79
+ </article>
80
+ );
81
+ }
82
+
83
+ function ThreadCard({
84
+ thread,
85
+ editor,
86
+ }: {
87
+ thread: PrCommentThread;
88
+ editor: EditorContext;
89
+ }) {
90
+ const location = thread.line
91
+ ? `${thread.path}:${thread.line}`
92
+ : thread.path || "arquivo";
93
+ const href = thread.absFile
94
+ ? editorFileLink(thread.absFile, thread.line, editor)
95
+ : null;
96
+ return (
97
+ <section class="comment-thread">
98
+ <header class="comment-thread-head">
99
+ <CodeIcon size={13} />
100
+ <span class="comment-file">{location}</span>
101
+ <span class="spacer" />
102
+ {href && (
103
+ <a
104
+ class="icon-btn"
105
+ href={href}
106
+ title={`Abrir ${location} no ${editorLabel(editor)}`}
107
+ >
108
+ <ExternalLink size={13} />
109
+ </a>
110
+ )}
111
+ <span class="count">{thread.comments.length}</span>
112
+ </header>
113
+ {thread.comments.map((comment, index) => (
114
+ <CommentCard
115
+ key={comment.id}
116
+ comment={comment}
117
+ editor={editor}
118
+ reply={index > 0}
119
+ />
120
+ ))}
121
+ </section>
122
+ );
123
+ }
124
+
125
+ export function CommentsPanel({ slug, editor }: { slug: string; editor: EditorContext }) {
126
+ const [data, setData] = useState<PrCommentsResponse | null>(null);
127
+ const [loading, setLoading] = useState(true);
128
+ const [error, setError] = useState("");
129
+
130
+ const load = useCallback(
131
+ async (refresh = false) => {
132
+ setLoading(true);
133
+ setError("");
134
+ try {
135
+ const result = await api<PrCommentsResponse>(
136
+ `/api/runs/${slug}/comments${refresh ? "?refresh=1" : ""}`,
137
+ );
138
+ setData(result);
139
+ } catch (err) {
140
+ setError(String(err));
141
+ } finally {
142
+ setLoading(false);
143
+ }
144
+ },
145
+ [slug],
146
+ );
147
+
148
+ useEffect(() => {
149
+ setData(null);
150
+ void load();
151
+ }, [load]);
152
+
153
+ const groups = useMemo(() => {
154
+ const map = new Map<string, PrCommentThread[]>();
155
+ for (const thread of data?.threads ?? []) {
156
+ const key = thread.path || "(sem arquivo)";
157
+ const list = map.get(key) ?? [];
158
+ list.push(thread);
159
+ map.set(key, list);
160
+ }
161
+ return [...map.entries()];
162
+ }, [data]);
163
+
164
+ const empty =
165
+ data?.available && data.threads.length === 0 && data.issueComments.length === 0;
166
+
167
+ return (
168
+ <div class="comments">
169
+ <div class="comments-toolbar">
170
+ <div class="comments-counts">
171
+ <MessageSquare size={14} />
172
+ {data?.available ? (
173
+ <>
174
+ <span>
175
+ <b>{data.counts.threads}</b> threads
176
+ </span>
177
+ <span>
178
+ <b>{data.counts.inline}</b> inline
179
+ </span>
180
+ <span>
181
+ <b>{data.counts.issue}</b> gerais
182
+ </span>
183
+ </>
184
+ ) : (
185
+ <span>Comentários da PR</span>
186
+ )}
187
+ {data?.target?.url && (
188
+ <a
189
+ class="pr-comment-link"
190
+ href={data.target.url}
191
+ target="_blank"
192
+ rel="noreferrer"
193
+ >
194
+ <GitHub size={12} /> PR #{data.target.number}
195
+ </a>
196
+ )}
197
+ </div>
198
+ <button
199
+ type="button"
200
+ class="btn"
201
+ disabled={loading}
202
+ title="Recarrega os comentários ignorando o cache de 60s"
203
+ onClick={() => void load(true)}
204
+ >
205
+ <Refresh size={14} class={loading ? "icon spin" : undefined} />
206
+ Atualizar
207
+ </button>
208
+ </div>
209
+
210
+ {loading && !data && <p class="empty">Carregando comentários da PR…</p>}
211
+ {error && <p class="error">{error}</p>}
212
+ {data && !data.available && (
213
+ <p class="empty">
214
+ Comentários indisponíveis
215
+ {data.reason ? `: ${data.reason}` : "."}
216
+ </p>
217
+ )}
218
+ {empty && <p class="empty">Nenhum comentário nesta PR.</p>}
219
+
220
+ {data?.available && groups.length > 0 && (
221
+ <section class="comment-group">
222
+ <h2 class="comment-group-title">Comentários de revisão</h2>
223
+ {groups.map(([path, threads]) => (
224
+ <div class="comment-file-group" key={path}>
225
+ <p class="comment-group-label">
226
+ {path} <span class="count">{threads.length}</span>
227
+ </p>
228
+ {threads.map((thread) => (
229
+ <ThreadCard
230
+ key={thread.id}
231
+ thread={thread}
232
+ editor={editor}
233
+ />
234
+ ))}
235
+ </div>
236
+ ))}
237
+ </section>
238
+ )}
239
+
240
+ {data?.available && data.issueComments.length > 0 && (
241
+ <section class="comment-group">
242
+ <h2 class="comment-group-title">Comentários gerais</h2>
243
+ {data.issueComments.map((comment) => (
244
+ <CommentCard
245
+ key={comment.id}
246
+ comment={comment}
247
+ editor={editor}
248
+ reply={false}
249
+ />
250
+ ))}
251
+ </section>
252
+ )}
253
+ </div>
254
+ );
255
+ }
@@ -21,8 +21,8 @@ import { MarkdownText } from "./MarkdownText";
21
21
  import { api } from "./utils/api";
22
22
  import { highlightCode, languageFromFile } from "./utils/code-highlight";
23
23
  import { DECISION_LABELS, DECISIONS, SEVERITY_LABELS } from "./utils/constants";
24
- import { vscodeFileLink } from "./utils/format";
25
- import type { Finding } from "./utils/types";
24
+ import { editorFileLink, editorLabel } from "./utils/format";
25
+ import type { EditorContext, Finding } from "./utils/types";
26
26
 
27
27
  /** Debounce do auto-save do comentário (ms). */
28
28
  const AUTOSAVE_MS = 700;
@@ -32,10 +32,12 @@ const SAVED_VISIBLE_MS = 1800;
32
32
  export function FindingCard({
33
33
  slug,
34
34
  finding,
35
+ editor,
35
36
  onUpdated,
36
37
  }: {
37
38
  slug: string;
38
39
  finding: Finding;
40
+ editor: EditorContext;
39
41
  onUpdated: (finding: Finding) => void;
40
42
  }) {
41
43
  const isDone = finding.execState === "done";
@@ -104,7 +106,9 @@ export function FindingCard({
104
106
  }, [comment, patch]);
105
107
 
106
108
  const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
107
- const href = finding.absFile ? vscodeFileLink(finding.absFile, finding.line) : null;
109
+ const href = finding.absFile
110
+ ? editorFileLink(finding.absFile, finding.line, editor)
111
+ : null;
108
112
  const highlighted = useMemo(
109
113
  () =>
110
114
  finding.evidence
@@ -132,7 +136,11 @@ export function FindingCard({
132
136
  </button>
133
137
  <span class="sev-badge">{SEVERITY_LABELS[finding.severity]}</span>
134
138
  {href ? (
135
- <a class="loc" href={href} title={`Abrir ${location} no VS Code`}>
139
+ <a
140
+ class="loc"
141
+ href={href}
142
+ title={`Abrir ${location} no ${editorLabel(editor)}`}
143
+ >
136
144
  <CodeIcon size={13} />
137
145
  {location}
138
146
  <ExternalLink size={12} class="ext" />
@@ -6,15 +6,17 @@ import { useMemo } from "preact/hooks";
6
6
  import { FindingCard } from "./FindingCard";
7
7
  import { Alert } from "./icons";
8
8
  import { SEVERITY_LABELS, SEVERITY_ORDER } from "./utils/constants";
9
- import type { Finding, Severity } from "./utils/types";
9
+ import type { EditorContext, Finding, Severity } from "./utils/types";
10
10
 
11
11
  export function FindingsList({
12
12
  slug,
13
13
  findings,
14
+ editor,
14
15
  onUpdated,
15
16
  }: {
16
17
  slug: string;
17
18
  findings: Finding[];
19
+ editor: EditorContext;
18
20
  onUpdated: (finding: Finding) => void;
19
21
  }) {
20
22
  const grouped = useMemo(() => {
@@ -45,6 +47,7 @@ export function FindingsList({
45
47
  key={finding.id}
46
48
  slug={slug}
47
49
  finding={finding}
50
+ editor={editor}
48
51
  onUpdated={onUpdated}
49
52
  />
50
53
  ))}