From a7f9592a2dfc660b384f19e9c963bebe3d99768c Mon Sep 17 00:00:00 2001 From: gandarfh Date: Sun, 14 Jun 2026 12:34:59 -0300 Subject: [PATCH] feat(desktop): flag secret env vars in the {{ref}} highlight --- .../src/components/layout/AppShell.tsx | 2 + .../layout/__tests__/AppShell.test.tsx | 3 ++ .../__tests__/useSecretEnvKeysSync.test.ts | 47 +++++++++++++++++ .../src/hooks/useSecretEnvKeysSync.ts | 25 +++++++++ .../blocks/__tests__/cm-references.test.ts | 43 ++++++++++++++++ httui-desktop/src/lib/blocks/cm-references.ts | 51 +++++++++++++++++-- .../src/lib/blocks/secret-env-keys.ts | 30 +++++++++++ 7 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 httui-desktop/src/hooks/__tests__/useSecretEnvKeysSync.test.ts create mode 100644 httui-desktop/src/hooks/useSecretEnvKeysSync.ts create mode 100644 httui-desktop/src/lib/blocks/secret-env-keys.ts diff --git a/httui-desktop/src/components/layout/AppShell.tsx b/httui-desktop/src/components/layout/AppShell.tsx index e9ce5925..d2166d56 100644 --- a/httui-desktop/src/components/layout/AppShell.tsx +++ b/httui-desktop/src/components/layout/AppShell.tsx @@ -22,6 +22,7 @@ import { useEditorSession } from "@/hooks/useEditorSession"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { useSidebarResize } from "@/hooks/useSidebarResize"; import { useSessionPersistence } from "@/hooks/useSessionPersistence"; +import { useSecretEnvKeysSync } from "@/hooks/useSecretEnvKeysSync"; import { WorkspaceContext } from "@/contexts/WorkspaceContext"; import { useAutoUpdate } from "@/hooks/useAutoUpdate"; import { ChatPanel } from "@/components/chat/ChatPanel"; @@ -75,6 +76,7 @@ export function AppShell() { useAutoUpdate(); useSessionPersistence(); usePendingSecretsScan(); + useSecretEnvKeysSync(); const { sidebarWidth, isResizing: isSidebarResizing, diff --git a/httui-desktop/src/components/layout/__tests__/AppShell.test.tsx b/httui-desktop/src/components/layout/__tests__/AppShell.test.tsx index af80a198..8a23b5eb 100644 --- a/httui-desktop/src/components/layout/__tests__/AppShell.test.tsx +++ b/httui-desktop/src/components/layout/__tests__/AppShell.test.tsx @@ -82,6 +82,9 @@ vi.mock("@/hooks/useSessionPersistence", () => ({ vi.mock("@/hooks/useAutoUpdate", () => ({ useAutoUpdate: vi.fn(), })); +vi.mock("@/hooks/useSecretEnvKeysSync", () => ({ + useSecretEnvKeysSync: vi.fn(), +})); import { AppShell } from "@/components/layout/AppShell"; import { useWorkspaceStore } from "@/stores/workspace"; diff --git a/httui-desktop/src/hooks/__tests__/useSecretEnvKeysSync.test.ts b/httui-desktop/src/hooks/__tests__/useSecretEnvKeysSync.test.ts new file mode 100644 index 00000000..8a4f22f3 --- /dev/null +++ b/httui-desktop/src/hooks/__tests__/useSecretEnvKeysSync.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; + +const bundlesMock = vi.fn(); +vi.mock("@/hooks/useCrossEnvVariables", () => ({ + useCrossEnvVariables: () => bundlesMock(), +})); + +import { useSecretEnvKeysSync } from "../useSecretEnvKeysSync"; +import { useEnvironmentStore } from "@/stores/environment"; +import { isSecretEnvKey, setSecretEnvKeys } from "@/lib/blocks/secret-env-keys"; + +const env = { id: "e1", name: "local", is_active: true } as never; + +beforeEach(() => { + setSecretEnvKeys([]); + useEnvironmentStore.setState({ activeEnvironment: env }); +}); +afterEach(() => { + useEnvironmentStore.setState({ activeEnvironment: null }); + vi.clearAllMocks(); +}); + +describe("useSecretEnvKeysSync", () => { + it("marks the active env's is_secret vars as secret keys", () => { + bundlesMock.mockReturnValue([ + { + env, + vars: [ + { key: "TOKEN", value: "", is_secret: true }, + { key: "BASE_URL", value: "x", is_secret: false }, + ], + }, + ]); + renderHook(() => useSecretEnvKeysSync()); + expect(isSecretEnvKey("TOKEN")).toBe(true); + expect(isSecretEnvKey("BASE_URL")).toBe(false); + }); + + it("clears the set when there is no active environment", () => { + setSecretEnvKeys(["TOKEN"]); + useEnvironmentStore.setState({ activeEnvironment: null }); + bundlesMock.mockReturnValue([]); + renderHook(() => useSecretEnvKeysSync()); + expect(isSecretEnvKey("TOKEN")).toBe(false); + }); +}); diff --git a/httui-desktop/src/hooks/useSecretEnvKeysSync.ts b/httui-desktop/src/hooks/useSecretEnvKeysSync.ts new file mode 100644 index 00000000..d5b7dc0b --- /dev/null +++ b/httui-desktop/src/hooks/useSecretEnvKeysSync.ts @@ -0,0 +1,25 @@ +import { useEffect } from "react"; +import { useCrossEnvVariables } from "@/hooks/useCrossEnvVariables"; +import { useEnvironmentStore } from "@/stores/environment"; +import { setSecretEnvKeys } from "@/lib/blocks/secret-env-keys"; + +/** + * Keeps the module-level secret-env-key set (read by the `{{ref}}` + * highlight) in sync with the active environment's keychain-backed + * variables. Uses the same reactive cross-env loader the Variables page + * uses, so it tracks vault file changes — unlike the store's one-shot + * startup refresh, which can run before the vault is ready. + */ +export function useSecretEnvKeysSync(): void { + const bundles = useCrossEnvVariables(); + const active = useEnvironmentStore((s) => s.activeEnvironment); + + useEffect(() => { + const bundle = active + ? bundles.find((b) => b.env.id === active.id) + : undefined; + setSecretEnvKeys( + (bundle?.vars ?? []).filter((v) => v.is_secret).map((v) => v.key), + ); + }, [bundles, active]); +} diff --git a/httui-desktop/src/lib/blocks/__tests__/cm-references.test.ts b/httui-desktop/src/lib/blocks/__tests__/cm-references.test.ts index c0b32abd..51dc98d5 100644 --- a/httui-desktop/src/lib/blocks/__tests__/cm-references.test.ts +++ b/httui-desktop/src/lib/blocks/__tests__/cm-references.test.ts @@ -5,6 +5,7 @@ import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { referenceHighlight } from "../cm-references"; +import { setSecretEnvKeys } from "../secret-env-keys"; function mount(doc: string) { const parent = document.createElement("div"); @@ -51,4 +52,46 @@ describe("referenceHighlight", () => { expect(view.dom.querySelectorAll(".cm-reference-highlight").length).toBe(0); view.destroy(); }); + + describe("secret env var marking", () => { + it("marks a bare {{KEY}} whose key is a secret env var", () => { + setSecretEnvKeys(["TOKEN"]); + const view = mount("Authorization: Bearer {{TOKEN}}"); + expect(view.dom.querySelectorAll(".cm-ref-secret").length).toBe(1); + view.destroy(); + setSecretEnvKeys([]); + }); + + it("does not mark a non-secret bare key", () => { + setSecretEnvKeys(["TOKEN"]); + const view = mount("{{BASE_URL}}"); + expect(view.dom.querySelectorAll(".cm-ref-secret").length).toBe(0); + view.destroy(); + setSecretEnvKeys([]); + }); + + it("does not mark a ref that has a path (block ref, not env var)", () => { + setSecretEnvKeys(["TOKEN"]); + // even if a path head collides with a secret key name, a dotted ref + // is a block reference, not the env var + const view = mount("{{TOKEN.body.id}}"); + expect(view.dom.querySelectorAll(".cm-ref-secret").length).toBe(0); + view.destroy(); + setSecretEnvKeys([]); + }); + + it("repaints when the secret set lands AFTER the editor mounted", () => { + // The set is populated asynchronously (post-IPC), usually after the + // editor has already painted. Without a forced rebuild the highlight + // would never appear until the next edit — the actual shipped bug. + const view = mount("Authorization: Bearer {{TOKEN}}"); + expect(view.dom.querySelectorAll(".cm-ref-secret").length).toBe(0); + + setSecretEnvKeys(["TOKEN"]); + expect(view.dom.querySelectorAll(".cm-ref-secret").length).toBe(1); + + view.destroy(); + setSecretEnvKeys([]); + }); + }); }); diff --git a/httui-desktop/src/lib/blocks/cm-references.ts b/httui-desktop/src/lib/blocks/cm-references.ts index af350d91..afd3ed54 100644 --- a/httui-desktop/src/lib/blocks/cm-references.ts +++ b/httui-desktop/src/lib/blocks/cm-references.ts @@ -10,10 +10,16 @@ import { EditorView, type ViewUpdate, } from "@codemirror/view"; -import { RangeSetBuilder } from "@codemirror/state"; +import { RangeSetBuilder, StateEffect } from "@codemirror/state"; import { parser } from "@httui/lezer-refs"; +import { isSecretEnvKey, subscribeSecretEnvKeys } from "./secret-env-keys"; + +// Dispatched when the secret-env-key set changes so the decoration plugin +// rebuilds even without a doc edit (the set lands asynchronously). +const secretKeysChanged = StateEffect.define(); const refMark = Decoration.mark({ class: "cm-reference-highlight" }); +const secretNameMark = Decoration.mark({ class: "cm-ref-name cm-ref-secret" }); const tokenMarks: Record = { Identifier: Decoration.mark({ class: "cm-ref-name" }), Prev: Decoration.mark({ class: "cm-ref-prev" }), @@ -32,11 +38,19 @@ function buildDeco(view: EditorView): DecorationSet { } else if (node.name === "Identifier") { // only the ref head (alias/env name) — path identifiers keep // the base highlight - if (node.node.parent?.name === "RefBody") { + const body = node.node.parent; + if (body?.name === "RefBody") { + // a bare `{{KEY}}` (no path) whose head matches a secret env + // var gets the secret class so keychain-backed vars read + // differently + const name = text.slice(node.from, node.to); + const bare = !text.slice(body.from, body.to).includes("."); builder.add( from + node.from, from + node.to, - tokenMarks.Identifier, + bare && isSecretEnvKey(name) + ? secretNameMark + : tokenMarks.Identifier, ); } } else if (node.name in tokenMarks) { @@ -51,14 +65,25 @@ function buildDeco(view: EditorView): DecorationSet { const referenceHighlightPlugin = ViewPlugin.fromClass( class { decorations: DecorationSet; + unsubscribe: () => void; constructor(view: EditorView) { this.decorations = buildDeco(view); + // Repaint when the secret-key set arrives (async, post-mount). + this.unsubscribe = subscribeSecretEnvKeys(() => { + view.dispatch({ effects: secretKeysChanged.of(null) }); + }); } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { + const secretsChanged = update.transactions.some((tr) => + tr.effects.some((e) => e.is(secretKeysChanged)), + ); + if (update.docChanged || update.viewportChanged || secretsChanged) { this.decorations = buildDeco(update.view); } } + destroy() { + this.unsubscribe(); + } }, { decorations: (v) => v.decorations }, ); @@ -79,6 +104,24 @@ const referenceHighlightTheme = EditorView.baseTheme({ ".cm-ref-index": { opacity: "0.8", }, + // keychain-backed env var: amber tint + dotted underline to read as + // "sensitive", distinct from the purple ref highlight. The color is a + // literal — this baseTheme is injected outside the Chakra provider's + // scope, so a `var(--chakra-colors-*)` here computes to an invalid + // color and the span silently inherits the parent ref's color. + ".cm-ref-secret": { + color: "rgb(217, 119, 6)", + textDecoration: "underline dotted", + textUnderlineOffset: "2px", + }, + // The body language's syntax highlighter wraps the ref text in its own + // token span (a generated `.ͼ…` class) nested INSIDE `.cm-ref-secret`; + // that innermost span's color would otherwise win, so force the amber + // through to every descendant. `!important` beats the highlighter's + // generated rule regardless of its injection order. + ".cm-ref-secret span": { + color: "rgb(217, 119, 6) !important", + }, ".cm-ref-tooltip": { fontFamily: "var(--chakra-fonts-mono)", fontSize: "11px", diff --git a/httui-desktop/src/lib/blocks/secret-env-keys.ts b/httui-desktop/src/lib/blocks/secret-env-keys.ts new file mode 100644 index 00000000..02f25ee8 --- /dev/null +++ b/httui-desktop/src/lib/blocks/secret-env-keys.ts @@ -0,0 +1,30 @@ +// Names of the active environment's secret (keychain-backed) variables. +// Kept as module-level non-reactive state — the `{{ref}}` highlight reads +// it synchronously per decoration pass, the same pattern the editor uses +// for content/unsaved sets. The environment store repopulates it whenever +// the active environment or its variables change. +let secretEnvKeys: ReadonlySet = new Set(); + +// The set is populated asynchronously (after an IPC round-trip), often +// AFTER the editor has already painted its decorations. CM6 only rebuilds +// decorations on a transaction, so the highlight extension subscribes here +// and forces a rebuild when the set lands — otherwise a freshly-opened note +// would never flag its secret refs until the next edit. +const listeners = new Set<() => void>(); + +export function setSecretEnvKeys(names: Iterable): void { + secretEnvKeys = new Set(names); + for (const listener of listeners) listener(); +} + +export function isSecretEnvKey(name: string): boolean { + return secretEnvKeys.has(name); +} + +/** Subscribe to set changes. Returns an unsubscribe function. */ +export function subscribeSecretEnvKeys(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}