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
236 changes: 231 additions & 5 deletions frontend/src/flow/GlobalsManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,38 @@ export function GlobalsManager({
onSecretsChange,
onClose,
}: Props) {
// Search query state
const [searchQuery, setSearchQuery] = useState("");

// Combine globals and secrets into a unified list for the sidebar
const items = useMemo(() => {
const gList = globals.map((g) => ({ ...g, type: "global" as const }));
const sList = secrets.map((s) => ({ ...s, type: "secret" as const }));
return [...gList, ...sList];
}, [globals, secrets]);

// Filter items in real-time based on the search query
const filteredItems = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return items;
return items.filter(
(item) =>
item.key.toLowerCase().includes(query) ||
item.value.toLowerCase().includes(query) ||
item.type.includes(query)
);
}, [items, searchQuery]);

const [selectedId, setSelectedId] = useState<string | null>(
items[0]?.id ?? null
filteredItems[0]?.id ?? null
);
Comment on lines +34 to 47

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Search matches secret values in plaintext, and filteredItems[0] in the initializer is misleading.

Two points on this block:

  1. item.value.toLowerCase().includes(query) also matches secret values. Since secrets are otherwise masked behind the password input and the show toggle, substring search lets a value be confirmed without revealing it deliberately. Consider restricting value matching to type === "global".
  2. useState(filteredItems[0]?.id ?? null) only evaluates on first render, when searchQuery is "" and filteredItems === items. Using items[0]?.id states the intent more accurately.
🔐 Proposed adjustment
     return items.filter(
       (item) =>
         item.key.toLowerCase().includes(query) ||
-        item.value.toLowerCase().includes(query) ||
+        (item.type === "global" && item.value.toLowerCase().includes(query)) ||
         item.type.includes(query)
     );
   }, [items, searchQuery]);
 
   const [selectedId, setSelectedId] = useState<string | null>(
-    filteredItems[0]?.id ?? null
+    items[0]?.id ?? null
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const filteredItems = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return items;
return items.filter(
(item) =>
item.key.toLowerCase().includes(query) ||
item.value.toLowerCase().includes(query) ||
item.type.includes(query)
);
}, [items, searchQuery]);
const [selectedId, setSelectedId] = useState<string | null>(
items[0]?.id ?? null
filteredItems[0]?.id ?? null
);
const filteredItems = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return items;
return items.filter(
(item) =>
item.key.toLowerCase().includes(query) ||
(item.type === "global" && item.value.toLowerCase().includes(query)) ||
item.type.includes(query)
);
}, [items, searchQuery]);
const [selectedId, setSelectedId] = useState<string | null>(
items[0]?.id ?? null
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/GlobalsManager.tsx` around lines 34 - 47, Update the
filteredItems search in GlobalsManager so item.value is considered only when
item.type === "global", while retaining key and type matching for all items.
Initialize selectedId from items[0]?.id instead of filteredItems[0]?.id to
reflect useState’s first-render behavior.

const [showSecretMap, setShowSecretMap] = useState<Record<string, boolean>>({});

// Import Overlay state
const [showImportModal, setShowImportModal] = useState(false);
const [importText, setImportText] = useState("");
const [importError, setImportError] = useState<string | null>(null);

const selected = useMemo(() => {
return items.find((item) => item.id === selectedId) ?? null;
}, [items, selectedId]);
Expand Down Expand Up @@ -91,6 +111,7 @@ export function GlobalsManager({
onSecretsChange([...secrets, newItem]);
}
setSelectedId(id);
setSearchQuery(""); // Clear search to make newly added item visible
};

const removeSelected = () => {
Expand All @@ -110,6 +131,120 @@ export function GlobalsManager({
}
};

const handleClearAll = () => {
if (items.length === 0) {
alert("No environment variables to clear.");
return;
}
if (confirm("Are you absolutely sure you want to clear ALL global variables and secrets? This action cannot be undone.")) {
onGlobalsChange([]);
onSecretsChange([]);
setSelectedId(null);
}
};

const handleExport = () => {
const data = {
globals,
secrets,
};
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
.then(() => alert("Environment configuration copied to clipboard!"))
.catch((err) => alert(`Failed to copy to clipboard: ${err}`));
};
Comment on lines +146 to +154

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Guard navigator.clipboard and warn before copying secrets in plaintext.

navigator.clipboard is undefined in non-secure contexts (and in some embedded webviews), so writeText throws synchronously — the .catch never runs and the click produces an uncaught TypeError. Also, this copies every secret value in cleartext to the system clipboard with no confirmation; at minimum confirm, or offer a secrets-excluded export.

🛡️ Proposed fix
   const handleExport = () => {
+    if (!navigator.clipboard?.writeText) {
+      alert("Clipboard access is unavailable in this context.");
+      return;
+    }
+    if (secrets.length > 0 && !confirm("This copies all secret values in plaintext to your clipboard. Continue?")) {
+      return;
+    }
     const data = {
       globals,
       secrets,
     };
     navigator.clipboard.writeText(JSON.stringify(data, null, 2))
       .then(() => alert("Environment configuration copied to clipboard!"))
       .catch((err) => alert(`Failed to copy to clipboard: ${err}`));
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleExport = () => {
const data = {
globals,
secrets,
};
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
.then(() => alert("Environment configuration copied to clipboard!"))
.catch((err) => alert(`Failed to copy to clipboard: ${err}`));
};
const handleExport = () => {
if (!navigator.clipboard?.writeText) {
alert("Clipboard access is unavailable in this context.");
return;
}
if (secrets.length > 0 && !confirm("This copies all secret values in plaintext to your clipboard. Continue?")) {
return;
}
const data = {
globals,
secrets,
};
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
.then(() => alert("Environment configuration copied to clipboard!"))
.catch((err) => alert(`Failed to copy to clipboard: ${err}`));
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/GlobalsManager.tsx` around lines 146 - 154, Update
handleExport to guard navigator.clipboard and handle unavailable or synchronous
write failures without uncaught errors, while preserving the existing
success/failure feedback. Before exporting secrets in plaintext, require
explicit user confirmation or provide a secrets-excluded export option; ensure
the clipboard payload matches that choice.


const handleImportSubmit = (mode: "merge" | "replace") => {
setImportError(null);
try {
const parsed = JSON.parse(importText.trim());
let importedGlobals: GlobalVar[] = [];
let importedSecrets: SecretVar[] = [];

if (Array.isArray(parsed)) {
// Flat array format
parsed.forEach((item: any) => {
if (item && typeof item === 'object' && typeof item.key === 'string' && typeof item.value === 'string') {
const type = item.type === 'secret' ? 'secret' : 'global';
const newItem = {
id: item.id || cryptoId(),
key: item.key.trim(),
value: item.value,
};
if (type === 'global') {
importedGlobals.push(newItem);
} else {
importedSecrets.push(newItem);
}
}
});
} else if (parsed && typeof parsed === 'object') {
// Nested format { globals: [...], secrets: [...] }
if (Array.isArray(parsed.globals)) {
parsed.globals.forEach((g: any) => {
if (g && typeof g === 'object' && typeof g.key === 'string' && typeof g.value === 'string') {
importedGlobals.push({
id: g.id || cryptoId(),
key: g.key.trim(),
value: g.value,
});
}
});
}
if (Array.isArray(parsed.secrets)) {
parsed.secrets.forEach((s: any) => {
if (s && typeof s === 'object' && typeof s.key === 'string' && typeof s.value === 'string') {
importedSecrets.push({
id: s.id || cryptoId(),
key: s.key.trim(),
value: s.value,
});
}
});
}
} else {
throw new Error("Invalid format. Expected a list or { globals, secrets } object.");
}

if (importedGlobals.length === 0 && importedSecrets.length === 0) {
throw new Error("No valid global variables or secrets found in the JSON.");
}
Comment on lines +163 to +210

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Imported entries bypass key validation and can reuse existing ids.

Two gaps in the validation loop:

  • Only typeof key === 'string' is checked, so "", "1BAD-KEY", or "has spaces" are imported and merely rendered with a ⚠ afterwards. Reuse validateKey's identifier regex here and skip/report offenders.
  • id: item.id || cryptoId() trusts caller-supplied ids. Merge dedupes by key, so an imported item carrying an id already present in globals/secrets yields duplicate ids — duplicate React keys in the sidebar and items.find((i) => i.id === selectedId) resolving to the wrong record. Safer to always mint a fresh id, or only keep item.id when it doesn't collide.

Same applies to the nested parsed.globals / parsed.secrets branches.

🐛 Proposed fix (flat-array branch shown; mirror for nested branch)
+const KEY_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+
       if (Array.isArray(parsed)) {
         // Flat array format
         parsed.forEach((item: any) => {
-          if (item && typeof item === 'object' && typeof item.key === 'string' && typeof item.value === 'string') {
+          if (
+            item && typeof item === 'object' &&
+            typeof item.key === 'string' && KEY_RE.test(item.key.trim()) &&
+            typeof item.value === 'string'
+          ) {
             const type = item.type === 'secret' ? 'secret' : 'global';
             const newItem = {
-              id: item.id || cryptoId(),
+              id: cryptoId(),
               key: item.key.trim(),
               value: item.value,
             };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/GlobalsManager.tsx` around lines 163 - 210, Update the
import validation in the flat-array and nested globals/secrets branches to
require keys accepted by validateKey, skipping or reporting invalid, empty,
malformed, or whitespace-containing keys. Replace unconditional reuse of
item.id, g.id, and s.id with fresh cryptoId values, or retain supplied IDs only
after verifying they do not collide with existing globals, secrets, or other
imported entries.


if (mode === "replace") {
onGlobalsChange(importedGlobals);
onSecretsChange(importedSecrets);
const remaining = [...importedGlobals.map((g) => g.id), ...importedSecrets.map((s) => s.id)];
setSelectedId(remaining[0] ?? null);
} else {
// Merge mode: keys must be unique. Update existing, append new.
const mergedGlobalsMap = new Map<string, GlobalVar>();
globals.forEach((g) => mergedGlobalsMap.set(g.key.toLowerCase(), g));
importedGlobals.forEach((ig) => {
mergedGlobalsMap.set(ig.key.toLowerCase(), ig);
});

const mergedSecretsMap = new Map<string, SecretVar>();
secrets.forEach((s) => mergedSecretsMap.set(s.key.toLowerCase(), s));
importedSecrets.forEach((is) => {
mergedSecretsMap.set(is.key.toLowerCase(), is);
});

const finalGlobals = Array.from(mergedGlobalsMap.values());
const finalSecrets = Array.from(mergedSecretsMap.values());

onGlobalsChange(finalGlobals);
onSecretsChange(finalSecrets);
const remaining = [...finalGlobals.map((g) => g.id), ...finalSecrets.map((s) => s.id)];
setSelectedId(remaining[0] ?? null);
}
Comment on lines +212 to +238

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Merge keeps cross-type key collisions, violating the uniqueness invariant.

mergedGlobalsMap and mergedSecretsMap are independent, so importing a secret named BASE_API while a global BASE_API exists leaves both in state. validateKey (Line 66) enforces uniqueness across globals and secrets, so the merged result is immediately flagged invalid and {{global.BASE_API}} / {{secret.BASE_API}} resolution becomes ambiguous. Decide a precedence rule (imported type wins, or reject the import) and drop the loser from the other collection.

🐛 Proposed fix — imported type wins on cross-type collision
         const finalGlobals = Array.from(mergedGlobalsMap.values());
         const finalSecrets = Array.from(mergedSecretsMap.values());
+
+        // A key may exist in only one collection; the imported type wins.
+        const importedGlobalKeys = new Set(importedGlobals.map((g) => g.key.toLowerCase()));
+        const importedSecretKeys = new Set(importedSecrets.map((s) => s.key.toLowerCase()));
+        const dedupedGlobals = finalGlobals.filter((g) => !importedSecretKeys.has(g.key.toLowerCase()));
+        const dedupedSecrets = finalSecrets.filter((s) => !importedGlobalKeys.has(s.key.toLowerCase()));
 
-        onGlobalsChange(finalGlobals);
-        onSecretsChange(finalSecrets);
-        const remaining = [...finalGlobals.map((g) => g.id), ...finalSecrets.map((s) => s.id)];
+        onGlobalsChange(dedupedGlobals);
+        onSecretsChange(dedupedSecrets);
+        const remaining = [...dedupedGlobals.map((g) => g.id), ...dedupedSecrets.map((s) => s.id)];
         setSelectedId(remaining[0] ?? null);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (mode === "replace") {
onGlobalsChange(importedGlobals);
onSecretsChange(importedSecrets);
const remaining = [...importedGlobals.map((g) => g.id), ...importedSecrets.map((s) => s.id)];
setSelectedId(remaining[0] ?? null);
} else {
// Merge mode: keys must be unique. Update existing, append new.
const mergedGlobalsMap = new Map<string, GlobalVar>();
globals.forEach((g) => mergedGlobalsMap.set(g.key.toLowerCase(), g));
importedGlobals.forEach((ig) => {
mergedGlobalsMap.set(ig.key.toLowerCase(), ig);
});
const mergedSecretsMap = new Map<string, SecretVar>();
secrets.forEach((s) => mergedSecretsMap.set(s.key.toLowerCase(), s));
importedSecrets.forEach((is) => {
mergedSecretsMap.set(is.key.toLowerCase(), is);
});
const finalGlobals = Array.from(mergedGlobalsMap.values());
const finalSecrets = Array.from(mergedSecretsMap.values());
onGlobalsChange(finalGlobals);
onSecretsChange(finalSecrets);
const remaining = [...finalGlobals.map((g) => g.id), ...finalSecrets.map((s) => s.id)];
setSelectedId(remaining[0] ?? null);
}
if (mode === "replace") {
onGlobalsChange(importedGlobals);
onSecretsChange(importedSecrets);
const remaining = [...importedGlobals.map((g) => g.id), ...importedSecrets.map((s) => s.id)];
setSelectedId(remaining[0] ?? null);
} else {
// Merge mode: keys must be unique. Update existing, append new.
const mergedGlobalsMap = new Map<string, GlobalVar>();
globals.forEach((g) => mergedGlobalsMap.set(g.key.toLowerCase(), g));
importedGlobals.forEach((ig) => {
mergedGlobalsMap.set(ig.key.toLowerCase(), ig);
});
const mergedSecretsMap = new Map<string, SecretVar>();
secrets.forEach((s) => mergedSecretsMap.set(s.key.toLowerCase(), s));
importedSecrets.forEach((is) => {
mergedSecretsMap.set(is.key.toLowerCase(), is);
});
const finalGlobals = Array.from(mergedGlobalsMap.values());
const finalSecrets = Array.from(mergedSecretsMap.values());
// A key may exist in only one collection; the imported type wins.
const importedGlobalKeys = new Set(importedGlobals.map((g) => g.key.toLowerCase()));
const importedSecretKeys = new Set(importedSecrets.map((s) => s.key.toLowerCase()));
const dedupedGlobals = finalGlobals.filter((g) => !importedSecretKeys.has(g.key.toLowerCase()));
const dedupedSecrets = finalSecrets.filter((s) => !importedGlobalKeys.has(s.key.toLowerCase()));
onGlobalsChange(dedupedGlobals);
onSecretsChange(dedupedSecrets);
const remaining = [...dedupedGlobals.map((g) => g.id), ...dedupedSecrets.map((s) => s.id)];
setSelectedId(remaining[0] ?? null);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/GlobalsManager.tsx` around lines 212 - 238, Update the
merge branch around mergedGlobalsMap and mergedSecretsMap to enforce key
uniqueness across both collections, not just within each type. Apply the
imported-type-wins rule: when an imported global or secret key matches an
existing or imported entry of the other type, remove the losing entry before
calling onGlobalsChange and onSecretsChange; preserve the existing same-type
update behavior and selection handling.


setShowImportModal(false);
setImportText("");
alert(`Imported successfully (${importedGlobals.length} globals, ${importedSecrets.length} secrets).`);
} catch (e) {
setImportError(e instanceof Error ? e.message : "Invalid JSON syntax");
}
};

const toggleSecretVisibility = (id: string) => {
setShowSecretMap((prev) => ({
...prev,
Expand All @@ -119,7 +254,7 @@ export function GlobalsManager({

return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[hsl(var(--ink)/0.35)] backdrop-blur-sm">
<div className="w-full max-w-3xl bg-[hsl(var(--paper))] border-2 border-[hsl(var(--ink))] flex flex-col h-[600px] max-h-[90vh]">
<div className="w-full max-w-3xl bg-[hsl(var(--paper))] border-2 border-[hsl(var(--ink))] flex flex-col h-[600px] max-h-[90vh] relative">

{/* Header */}
<div
Expand Down Expand Up @@ -147,6 +282,17 @@ export function GlobalsManager({

{/* Sidebar */}
<aside className="w-[220px] shrink-0 border-r border-dashed border-[hsl(var(--grid-line))] flex flex-col bg-[hsl(var(--paper))]">
{/* Search Input */}
<div className="p-2 border-b border-dashed border-[hsl(var(--grid-line))]">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search keys, values..."
className="w-full bg-transparent border border-dashed border-[hsl(var(--ink-faint))] focus:border-[hsl(var(--ink))] outline-none py-1 px-2 font-mono text-[10px] text-[hsl(var(--ink))]"
/>
</div>

<div className="p-2 border-b border-dashed border-[hsl(var(--grid-line))] space-y-1">
<button
onClick={() => addVariable("global")}
Expand All @@ -165,12 +311,12 @@ export function GlobalsManager({
</div>

<div className="flex-1 overflow-y-auto">
{items.length === 0 && (
{filteredItems.length === 0 && (
<div className="p-3 font-mono text-[10px] text-[hsl(var(--ink-faint))] leading-relaxed text-center">
No globals or secrets configured yet.
{items.length === 0 ? "No variables configured yet." : "No matching variables found."}
</div>
)}
{items.map((item) => {
{filteredItems.map((item) => {
const isSel = selectedId === item.id;
const bad = !!validateKey(item.key);
return (
Expand Down Expand Up @@ -202,6 +348,33 @@ export function GlobalsManager({
);
})}
</div>

{/* Sidebar actions: Clear, Import, Export */}
<div className="p-2 border-t border-dashed border-[hsl(var(--grid-line))] space-y-1 bg-[hsl(var(--ink)/0.01)]">
<div className="grid grid-cols-2 gap-1">
<button
onClick={handleExport}
title="Export all to clipboard as JSON"
className="w-full font-mono text-[9px] uppercase px-1.5 py-1 border border-dashed border-[hsl(var(--ink-faint))] hover:bg-[hsl(var(--ink))] hover:text-[hsl(var(--paper))] text-center"
>
Export
</button>
<button
onClick={() => setShowImportModal(true)}
title="Import from JSON"
className="w-full font-mono text-[9px] uppercase px-1.5 py-1 border border-dashed border-[hsl(var(--ink-faint))] hover:bg-[hsl(var(--ink))] hover:text-[hsl(var(--paper))] text-center"
>
Import
</button>
</div>
<button
onClick={handleClearAll}
title="Wipe all variables and secrets"
className="w-full font-mono text-[9px] uppercase px-1.5 py-1 border border-dashed text-[hsl(var(--issue))] border-[hsl(var(--issue))] hover:bg-[hsl(var(--issue))] hover:text-[hsl(var(--paper))] text-center"
>
Clear All
</button>
</div>
</aside>

{/* Editor Area */}
Expand Down Expand Up @@ -341,6 +514,59 @@ export function GlobalsManager({
</div>

</div>

{/* Import Modal Overlay */}
{showImportModal && (
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-[hsl(var(--ink)/0.4)] backdrop-blur-xs">
<div className="w-[90%] max-w-lg bg-[hsl(var(--paper))] border-2 border-[hsl(var(--ink))] p-4 flex flex-col space-y-3">
<div className="flex justify-between items-center border-b border-dashed border-[hsl(var(--grid-line))] pb-2">
<span className="font-mono text-xs font-bold uppercase tracking-wider">Import Environment Variables</span>
<button
onClick={() => {
setShowImportModal(false);
setImportError(null);
setImportText("");
}}
className="font-mono text-[10px] border border-dashed px-1.5 py-0.5 hover:bg-[hsl(var(--ink))] hover:text-[hsl(var(--paper))]"
>
Close
</button>
</div>
<p className="font-mono text-[10px] text-[hsl(var(--ink-soft))] leading-relaxed">
Paste a JSON array of variables, or an object in the format:
<code className="text-[hsl(var(--ink))] block bg-[hsl(var(--ink)/0.03)] p-1 mt-1 font-semibold">
{`{ "globals": [{ "key": "K", "value": "V" }], "secrets": [...] }`}
</code>
</p>
<textarea
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder='Paste JSON content here...'
rows={8}
className="w-full bg-transparent border border-dashed border-[hsl(var(--ink-faint))] focus:border-[hsl(var(--ink))] outline-none p-2 font-mono text-[11px] text-[hsl(var(--ink))] resize-y"
/>
{importError && (
<p className="text-[10px] text-[hsl(var(--issue))] font-mono">
⚠ {importError}
</p>
)}
<div className="flex gap-2 pt-1">
<button
onClick={() => handleImportSubmit("merge")}
className="flex-1 font-mono text-[10px] uppercase tracking-wider py-2 bg-[hsl(var(--ink))] text-[hsl(var(--paper))] hover:opacity-90 font-bold transition-all"
>
Merge-Import
</button>
<button
onClick={() => handleImportSubmit("replace")}
className="flex-1 font-mono text-[10px] uppercase tracking-wider py-2 border border-dashed border-[hsl(var(--issue))] text-[hsl(var(--issue))] hover:bg-[hsl(var(--issue))] hover:text-[hsl(var(--paper))] transition-all"
>
Replace-Import
</button>
</div>
</div>
</div>
)}
</div>
);
}
Loading