Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions httui-desktop/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -75,6 +76,7 @@ export function AppShell() {
useAutoUpdate();
useSessionPersistence();
usePendingSecretsScan();
useSecretEnvKeysSync();
const {
sidebarWidth,
isResizing: isSidebarResizing,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
47 changes: 47 additions & 0 deletions httui-desktop/src/hooks/__tests__/useSecretEnvKeysSync.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
25 changes: 25 additions & 0 deletions httui-desktop/src/hooks/useSecretEnvKeysSync.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
43 changes: 43 additions & 0 deletions httui-desktop/src/lib/blocks/__tests__/cm-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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([]);
});
});
});
51 changes: 47 additions & 4 deletions httui-desktop/src/lib/blocks/cm-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<null>();

const refMark = Decoration.mark({ class: "cm-reference-highlight" });
const secretNameMark = Decoration.mark({ class: "cm-ref-name cm-ref-secret" });
const tokenMarks: Record<string, Decoration> = {
Identifier: Decoration.mark({ class: "cm-ref-name" }),
Prev: Decoration.mark({ class: "cm-ref-prev" }),
Expand All @@ -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) {
Expand All @@ -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 },
);
Expand All @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions httui-desktop/src/lib/blocks/secret-env-keys.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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<string>): 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);
};
}
Loading