fix: address critical PR feedback for export/import functionality

- Extract shared ImportModeDialog component to eliminate code duplication between ModeSelector and ModesView
- Add comprehensive test coverage for export/import functionality in ModeSelector
- Implement user-facing error notifications with auto-dismiss for import/export operations
- Refactor ModeSelector by extracting ModeSelectorFooter component and useModeSelectorExportImport hook
- Reduce component complexity and improve maintainability
- Remove unused IconButton import

Fixes all critical issues identified in PR #6318 review
This commit is contained in:
Roo Code 2025-07-28 23:31:26 +00:00
parent 956891bc45
commit 642115c4b0
7 changed files with 712 additions and 204 deletions

View file

@ -2,8 +2,7 @@ import React from "react"
import { ChevronUp, Check, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip, Button } from "@/components/ui"
import { IconButton } from "./IconButton"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
import { vscode } from "@/utils/vscode"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useAppTranslation } from "@/i18n/TranslationContext"
@ -12,6 +11,9 @@ import { ModeConfig, CustomModePrompts } from "@roo-code/types"
import { telemetryClient } from "@/utils/TelemetryClient"
import { TelemetryEventName } from "@roo-code/types"
import { Fzf } from "fzf"
import { ImportModeDialog } from "@/components/common/ImportModeDialog"
import { ModeSelectorFooter } from "./ModeSelectorFooter"
import { useModeSelectorExportImport } from "./useModeSelectorExportImport"
// Minimum number of modes required to show search functionality
const SEARCH_THRESHOLD = 6
@ -45,8 +47,17 @@ export const ModeSelector = ({
const portalContainer = useRooPortal("roo-portal")
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
const { t } = useAppTranslation()
const [showImportDialog, setShowImportDialog] = React.useState(false)
const [isImporting, setIsImporting] = React.useState(false)
const {
showImportDialog,
isImporting,
exportError,
importError,
handleExport,
handleImport,
openImportDialog,
closeImportDialog,
clearErrors,
} = useModeSelectorExportImport()
const trackModeSelectorOpened = React.useCallback(() => {
// Track telemetry every time the mode selector is opened
@ -155,23 +166,6 @@ export const ModeSelector = ({
}
}, [open])
// Handle import/export result messages
React.useEffect(() => {
const handler = (event: MessageEvent) => {
const message = event.data
if (message.type === "importModeResult") {
setIsImporting(false)
setShowImportDialog(false)
if (!message.success && message.error !== "cancelled") {
console.error("Failed to import mode:", message.error)
}
}
}
window.addEventListener("message", handler)
return () => window.removeEventListener("message", handler)
}, [])
// Determine if search should be shown
const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD
@ -273,131 +267,44 @@ export const ModeSelector = ({
</div>
{/* Bottom bar with buttons on left and title on right */}
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
<div className="flex flex-row gap-1">
<IconButton
iconClass="codicon-extensions"
title={t("chat:modeSelector.marketplace")}
onClick={() => {
window.postMessage(
{
type: "action",
action: "marketplaceButtonClicked",
values: { marketplaceTab: "mode" },
},
"*",
)
setOpen(false)
}}
/>
<IconButton
iconClass="codicon-export"
title={t("prompts:exportMode.title")}
onClick={() => {
if (value) {
vscode.postMessage({
type: "exportMode",
slug: value,
})
}
setOpen(false)
}}
/>
<IconButton
iconClass="codicon-import"
title={t("prompts:modes.importMode")}
onClick={() => {
setShowImportDialog(true)
setOpen(false)
}}
/>
<IconButton
iconClass="codicon-settings-gear"
title={t("chat:modeSelector.settings")}
onClick={() => {
vscode.postMessage({
type: "switchTab",
tab: "modes",
})
setOpen(false)
}}
/>
</div>
{/* Info icon and title on the right - only show info icon when search bar is visible */}
<div className="flex items-center gap-1 pr-1">
{showSearch && (
<StandardTooltip content={instructionText}>
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
</StandardTooltip>
)}
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
{t("chat:modeSelector.title")}
</h4>
</div>
</div>
<ModeSelectorFooter
selectedMode={value}
showSearch={showSearch}
instructionText={instructionText}
onExport={() => handleExport(value)}
onImport={openImportDialog}
onClose={() => setOpen(false)}
/>
</div>
</PopoverContent>
</Popover>
{/* Import Mode Dialog */}
{showImportDialog && (
<div className="fixed inset-0 flex items-center justify-center bg-black/50 z-[1000]">
<div className="bg-vscode-editor-background border border-vscode-editor-lineHighlightBorder rounded-lg shadow-lg p-6 max-w-md w-full">
<h3 className="text-lg font-semibold mb-4">{t("prompts:modes.importMode")}</h3>
<p className="text-sm text-vscode-descriptionForeground mb-4">
{t("prompts:importMode.selectLevel")}
</p>
<div className="space-y-3 mb-6">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
name="importLevel"
value="project"
className="mt-1"
defaultChecked
/>
<div>
<div className="font-medium">{t("prompts:importMode.project.label")}</div>
<div className="text-xs text-vscode-descriptionForeground">
{t("prompts:importMode.project.description")}
</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input type="radio" name="importLevel" value="global" className="mt-1" />
<div>
<div className="font-medium">{t("prompts:importMode.global.label")}</div>
<div className="text-xs text-vscode-descriptionForeground">
{t("prompts:importMode.global.description")}
</div>
</div>
</label>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setShowImportDialog(false)}>
{t("prompts:createModeDialog.buttons.cancel")}
</Button>
<Button
variant="default"
onClick={() => {
if (!isImporting) {
const selectedLevel = (
document.querySelector(
'input[name="importLevel"]:checked',
) as HTMLInputElement
)?.value as "global" | "project"
setIsImporting(true)
vscode.postMessage({
type: "importMode",
source: selectedLevel || "project",
})
}
}}
disabled={isImporting}>
{isImporting ? t("prompts:importMode.importing") : t("prompts:importMode.import")}
</Button>
<ImportModeDialog
isOpen={showImportDialog}
onClose={closeImportDialog}
onImport={handleImport}
isImporting={isImporting}
/>
{/* Error notifications */}
{(exportError || importError) && (
<div className="fixed bottom-4 right-4 max-w-sm bg-vscode-notifications-background border border-vscode-notifications-border rounded-md shadow-lg p-4 z-[1001]">
<div className="flex items-start gap-2">
<span className="codicon codicon-error text-vscode-errorForeground flex-shrink-0 mt-0.5"></span>
<div className="flex-1">
<div className="text-sm font-medium text-vscode-notifications-foreground">
{exportError ? t("prompts:exportMode.errorTitle") : t("prompts:importMode.errorTitle")}
</div>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{exportError || importError}
</div>
</div>
<button
onClick={clearErrors}
className="text-vscode-icon-foreground hover:text-vscode-foreground">
<X className="h-4 w-4" />
</button>
</div>
</div>
)}

View file

@ -0,0 +1,96 @@
import React from "react"
import { IconButton } from "./IconButton"
import { StandardTooltip } from "@/components/ui"
import { vscode } from "@/utils/vscode"
import { useAppTranslation } from "@/i18n/TranslationContext"
interface ModeSelectorFooterProps {
selectedMode: string | null
showSearch: boolean
instructionText: string
onExport: () => void
onImport: () => void
onClose: () => void
}
export const ModeSelectorFooter: React.FC<ModeSelectorFooterProps> = ({
selectedMode,
showSearch,
instructionText,
onExport,
onImport,
onClose,
}) => {
const { t } = useAppTranslation()
const handleMarketplaceClick = () => {
window.postMessage(
{
type: "action",
action: "marketplaceButtonClicked",
values: { marketplaceTab: "mode" },
},
"*",
)
onClose()
}
const handleExportClick = () => {
if (selectedMode) {
onExport()
}
onClose()
}
const handleImportClick = () => {
onImport()
onClose()
}
const handleSettingsClick = () => {
vscode.postMessage({
type: "switchTab",
tab: "modes",
})
onClose()
}
return (
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
<div className="flex flex-row gap-1">
<IconButton
iconClass="codicon-extensions"
title={t("chat:modeSelector.marketplace")}
onClick={handleMarketplaceClick}
/>
<IconButton
iconClass="codicon-export"
title={t("prompts:exportMode.title")}
onClick={handleExportClick}
/>
<IconButton
iconClass="codicon-import"
title={t("prompts:modes.importMode")}
onClick={handleImportClick}
/>
<IconButton
iconClass="codicon-settings-gear"
title={t("chat:modeSelector.settings")}
onClick={handleSettingsClick}
/>
</div>
{/* Info icon and title on the right - only show info icon when search bar is visible */}
<div className="flex items-center gap-1 pr-1">
{showSearch && (
<StandardTooltip content={instructionText}>
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
</StandardTooltip>
)}
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
{t("chat:modeSelector.title")}
</h4>
</div>
</div>
)
}

View file

@ -1,9 +1,10 @@
import React from "react"
import { render, screen, fireEvent } from "@/utils/test-utils"
import { describe, test, expect, vi } from "vitest"
import { render, screen, fireEvent, waitFor } from "@/utils/test-utils"
import { describe, test, expect, vi, beforeEach } from "vitest"
import ModeSelector from "../ModeSelector"
import { Mode } from "@roo/modes"
import { ModeConfig } from "@roo-code/types"
import { vscode } from "@/utils/vscode"
// Mock the dependencies
vi.mock("@/utils/vscode", () => ({
@ -12,6 +13,9 @@ vi.mock("@/utils/vscode", () => ({
},
}))
// Spy on window.postMessage
const windowPostMessageSpy = vi.spyOn(window, "postMessage")
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
hasOpenedModeSelector: false,
@ -47,6 +51,12 @@ vi.mock("@roo/modes", async () => {
})
describe("ModeSelector", () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mock modes
mockModes = []
})
test("shows custom description from customModePrompts", () => {
const customModePrompts = {
code: {
@ -199,4 +209,259 @@ describe("ModeSelector", () => {
const infoIcon = document.querySelector(".codicon-info")
expect(infoIcon).toBeInTheDocument()
})
describe("Export functionality", () => {
test("export button triggers export message", () => {
// Set up mock to return a few modes
mockModes = Array.from({ length: 3 }, (_, i) => ({
slug: `mode-${i}`,
name: `Mode ${i}`,
description: `Description for mode ${i}`,
roleDefinition: "Role definition",
groups: ["read", "edit"],
}))
render(<ModeSelector value={"mode-0" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Click to open the popover
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
// Find and click the export button
const exportButton = screen.getByLabelText("prompts:exportMode.title")
fireEvent.click(exportButton)
// Should have sent export message
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "exportMode",
slug: "mode-0",
})
})
test("export error is displayed when export fails", async () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Simulate export error message
const errorEvent = new MessageEvent("message", {
data: {
type: "exportModeResult",
success: false,
error: "Failed to export mode",
},
})
window.dispatchEvent(errorEvent)
// Error notification should be displayed
await waitFor(() => {
expect(screen.getByText("prompts:exportMode.errorTitle")).toBeInTheDocument()
expect(screen.getByText("Failed to export mode")).toBeInTheDocument()
})
})
})
describe("Import functionality", () => {
test("import button opens import dialog", () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Click to open the popover
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
// Find and click the import button
const importButton = screen.getByLabelText("prompts:modes.importMode")
fireEvent.click(importButton)
// Import dialog should be displayed
expect(screen.getByText("prompts:modes.importMode")).toBeInTheDocument()
expect(screen.getByText("prompts:importMode.selectLevel")).toBeInTheDocument()
})
test("import dialog allows selection between project and global", () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Open popover and click import
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
const importButton = screen.getByLabelText("prompts:modes.importMode")
fireEvent.click(importButton)
// Check radio buttons are present
const projectRadio = screen.getByLabelText(/prompts:importMode.project.label/)
const globalRadio = screen.getByLabelText(/prompts:importMode.global.label/)
expect(projectRadio).toBeInTheDocument()
expect(globalRadio).toBeInTheDocument()
expect(projectRadio).toBeChecked()
expect(globalRadio).not.toBeChecked()
})
test("import dialog cancel button closes dialog", () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Open import dialog
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
fireEvent.click(screen.getByLabelText("prompts:modes.importMode"))
// Click cancel
const cancelButton = screen.getByText("prompts:createModeDialog.buttons.cancel")
fireEvent.click(cancelButton)
// Dialog should be closed
expect(screen.queryByText("prompts:importMode.selectLevel")).not.toBeInTheDocument()
})
test("import dialog import button triggers import message", () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Open import dialog
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
fireEvent.click(screen.getByLabelText("prompts:modes.importMode"))
// Select global option
const globalRadio = screen.getByLabelText(/prompts:importMode.global.label/)
fireEvent.click(globalRadio)
// Click import
const importButton = screen.getByText("prompts:importMode.import")
fireEvent.click(importButton)
// Should have sent import message
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "importMode",
source: "global",
})
})
test("import error is displayed when import fails", async () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Simulate import error message
const errorEvent = new MessageEvent("message", {
data: {
type: "importModeResult",
success: false,
error: "Failed to import mode",
},
})
window.dispatchEvent(errorEvent)
// Error notification should be displayed
await waitFor(() => {
expect(screen.getByText("prompts:importMode.errorTitle")).toBeInTheDocument()
expect(screen.getByText("Failed to import mode")).toBeInTheDocument()
})
})
test("import dialog closes on successful import", async () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Open import dialog
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
fireEvent.click(screen.getByLabelText("prompts:modes.importMode"))
// Dialog should be open
expect(screen.getByText("prompts:importMode.selectLevel")).toBeInTheDocument()
// Simulate successful import message
const successEvent = new MessageEvent("message", {
data: {
type: "importModeResult",
success: true,
},
})
window.dispatchEvent(successEvent)
// Dialog should be closed
await waitFor(() => {
expect(screen.queryByText("prompts:importMode.selectLevel")).not.toBeInTheDocument()
})
})
test("cancelled import does not show error", async () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Simulate cancelled import message
const cancelEvent = new MessageEvent("message", {
data: {
type: "importModeResult",
success: false,
error: "cancelled",
},
})
window.dispatchEvent(cancelEvent)
// No error notification should be displayed
await waitFor(() => {
expect(screen.queryByText("prompts:importMode.errorTitle")).not.toBeInTheDocument()
})
})
})
describe("Bottom bar buttons", () => {
test("marketplace button sends correct message", () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Click to open the popover
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
// Find and click the marketplace button
const marketplaceButton = screen.getByLabelText("chat:modeSelector.marketplace")
fireEvent.click(marketplaceButton)
// Should have sent marketplace message
expect(windowPostMessageSpy).toHaveBeenCalledWith(
{
type: "action",
action: "marketplaceButtonClicked",
values: { marketplaceTab: "mode" },
},
"*",
)
})
test("settings button sends correct message", () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Click to open the popover
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
// Find and click the settings button
const settingsButton = screen.getByLabelText("chat:modeSelector.settings")
fireEvent.click(settingsButton)
// Should have sent switch tab message
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "switchTab",
tab: "modes",
})
})
})
describe("Error notification behavior", () => {
test("error notification can be closed manually", async () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Simulate export error
const errorEvent = new MessageEvent("message", {
data: {
type: "exportModeResult",
success: false,
error: "Test error",
},
})
window.dispatchEvent(errorEvent)
// Error should be displayed
await waitFor(() => {
expect(screen.getByText("Test error")).toBeInTheDocument()
})
// Click close button - find the button with X icon inside the error notification
const errorNotification = screen.getByText("Test error").closest("div")?.parentElement?.parentElement
const closeButton = errorNotification?.querySelector("button:last-child")
if (closeButton) {
fireEvent.click(closeButton)
}
// Error should be gone
expect(screen.queryByText("Test error")).not.toBeInTheDocument()
})
})
})

View file

@ -0,0 +1,78 @@
import React from "react"
import { vscode } from "@/utils/vscode"
import { useAppTranslation } from "@/i18n/TranslationContext"
export const useModeSelectorExportImport = () => {
const { t } = useAppTranslation()
const [showImportDialog, setShowImportDialog] = React.useState(false)
const [isImporting, setIsImporting] = React.useState(false)
const [exportError, setExportError] = React.useState<string | null>(null)
const [importError, setImportError] = React.useState<string | null>(null)
// Handle import/export result messages
React.useEffect(() => {
const handler = (event: MessageEvent) => {
const message = event.data
if (message.type === "importModeResult") {
setIsImporting(false)
setShowImportDialog(false)
if (!message.success && message.error !== "cancelled") {
setImportError(message.error || t("prompts:importMode.error"))
// Clear error after 5 seconds
setTimeout(() => setImportError(null), 5000)
}
} else if (message.type === "exportModeResult") {
if (!message.success) {
setExportError(message.error || t("prompts:exportMode.error"))
// Clear error after 5 seconds
setTimeout(() => setExportError(null), 5000)
}
}
}
window.addEventListener("message", handler)
return () => window.removeEventListener("message", handler)
}, [t])
const handleExport = (modeSlug: string) => {
setExportError(null)
vscode.postMessage({
type: "exportMode",
slug: modeSlug,
})
}
const handleImport = (source: "global" | "project") => {
setIsImporting(true)
vscode.postMessage({
type: "importMode",
source,
})
}
const openImportDialog = () => {
setImportError(null)
setShowImportDialog(true)
}
const closeImportDialog = () => {
setShowImportDialog(false)
}
const clearErrors = () => {
setExportError(null)
setImportError(null)
}
return {
showImportDialog,
isImporting,
exportError,
importError,
handleExport,
handleImport,
openImportDialog,
closeImportDialog,
clearErrors,
}
}

View file

@ -0,0 +1,64 @@
import React from "react"
import { Button } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
interface ImportModeDialogProps {
isOpen: boolean
onClose: () => void
onImport: (source: "global" | "project") => void
isImporting?: boolean
}
export const ImportModeDialog: React.FC<ImportModeDialogProps> = ({
isOpen,
onClose,
onImport,
isImporting = false,
}) => {
const { t } = useAppTranslation()
if (!isOpen) return null
const handleImport = () => {
const selectedLevel = (document.querySelector('input[name="importLevel"]:checked') as HTMLInputElement)
?.value as "global" | "project"
onImport(selectedLevel || "project")
}
return (
<div className="fixed inset-0 flex items-center justify-center bg-black/50 z-[1000]">
<div className="bg-vscode-editor-background border border-vscode-editor-lineHighlightBorder rounded-lg shadow-lg p-6 max-w-md w-full">
<h3 className="text-lg font-semibold mb-4">{t("prompts:modes.importMode")}</h3>
<p className="text-sm text-vscode-descriptionForeground mb-4">{t("prompts:importMode.selectLevel")}</p>
<div className="space-y-3 mb-6">
<label className="flex items-start gap-2 cursor-pointer">
<input type="radio" name="importLevel" value="project" className="mt-1" defaultChecked />
<div>
<div className="font-medium">{t("prompts:importMode.project.label")}</div>
<div className="text-xs text-vscode-descriptionForeground">
{t("prompts:importMode.project.description")}
</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input type="radio" name="importLevel" value="global" className="mt-1" />
<div>
<div className="font-medium">{t("prompts:importMode.global.label")}</div>
<div className="text-xs text-vscode-descriptionForeground">
{t("prompts:importMode.global.description")}
</div>
</div>
</label>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
{t("prompts:createModeDialog.buttons.cancel")}
</Button>
<Button variant="default" onClick={handleImport} disabled={isImporting}>
{isImporting ? t("prompts:importMode.importing") : t("prompts:importMode.import")}
</Button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,111 @@
import React from "react"
import { render, screen, fireEvent } from "@/utils/test-utils"
import { describe, test, expect, vi } from "vitest"
import { ImportModeDialog } from "../ImportModeDialog"
// Mock the translation hook
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
describe("ImportModeDialog", () => {
test("renders nothing when not open", () => {
const { container } = render(<ImportModeDialog isOpen={false} onClose={vi.fn()} onImport={vi.fn()} />)
expect(container.firstChild).toBeNull()
})
test("renders dialog when open", () => {
render(<ImportModeDialog isOpen={true} onClose={vi.fn()} onImport={vi.fn()} />)
expect(screen.getByText("prompts:modes.importMode")).toBeInTheDocument()
expect(screen.getByText("prompts:importMode.selectLevel")).toBeInTheDocument()
})
test("project option is selected by default", () => {
render(<ImportModeDialog isOpen={true} onClose={vi.fn()} onImport={vi.fn()} />)
const projectRadio = screen.getByLabelText(/prompts:importMode.project.label/)
const globalRadio = screen.getByLabelText(/prompts:importMode.global.label/)
expect(projectRadio).toBeChecked()
expect(globalRadio).not.toBeChecked()
})
test("can switch between project and global options", () => {
render(<ImportModeDialog isOpen={true} onClose={vi.fn()} onImport={vi.fn()} />)
const projectRadio = screen.getByLabelText(/prompts:importMode.project.label/)
const globalRadio = screen.getByLabelText(/prompts:importMode.global.label/)
// Initially project is selected
expect(projectRadio).toBeChecked()
expect(globalRadio).not.toBeChecked()
// Click global
fireEvent.click(globalRadio)
// Now global should be selected
expect(projectRadio).not.toBeChecked()
expect(globalRadio).toBeChecked()
})
test("cancel button calls onClose", () => {
const onClose = vi.fn()
render(<ImportModeDialog isOpen={true} onClose={onClose} onImport={vi.fn()} />)
const cancelButton = screen.getByText("prompts:createModeDialog.buttons.cancel")
fireEvent.click(cancelButton)
expect(onClose).toHaveBeenCalledTimes(1)
})
test("import button calls onImport with selected source", () => {
const onImport = vi.fn()
render(<ImportModeDialog isOpen={true} onClose={vi.fn()} onImport={onImport} />)
// Select global
const globalRadio = screen.getByLabelText(/prompts:importMode.global.label/)
fireEvent.click(globalRadio)
// Click import
const importButton = screen.getByText("prompts:importMode.import")
fireEvent.click(importButton)
expect(onImport).toHaveBeenCalledWith("global")
})
test("import button calls onImport with project by default", () => {
const onImport = vi.fn()
render(<ImportModeDialog isOpen={true} onClose={vi.fn()} onImport={onImport} />)
// Click import without changing selection
const importButton = screen.getByText("prompts:importMode.import")
fireEvent.click(importButton)
expect(onImport).toHaveBeenCalledWith("project")
})
test("import button is disabled when isImporting is true", () => {
render(<ImportModeDialog isOpen={true} onClose={vi.fn()} onImport={vi.fn()} isImporting={true} />)
const importButton = screen.getByText("prompts:importMode.importing")
expect(importButton).toBeDisabled()
})
test("shows importing text when isImporting is true", () => {
render(<ImportModeDialog isOpen={true} onClose={vi.fn()} onImport={vi.fn()} isImporting={true} />)
expect(screen.getByText("prompts:importMode.importing")).toBeInTheDocument()
expect(screen.queryByText("prompts:importMode.import")).not.toBeInTheDocument()
})
test("displays project and global descriptions", () => {
render(<ImportModeDialog isOpen={true} onClose={vi.fn()} onImport={vi.fn()} />)
expect(screen.getByText("prompts:importMode.project.description")).toBeInTheDocument()
expect(screen.getByText("prompts:importMode.global.description")).toBeInTheDocument()
})
})

View file

@ -48,6 +48,7 @@ import {
StandardTooltip,
} from "@src/components/ui"
import { DeleteModeDialog } from "@src/components/modes/DeleteModeDialog"
import { ImportModeDialog } from "@src/components/common/ImportModeDialog"
// Get all available groups that should show in prompts view
const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable)
@ -96,6 +97,8 @@ const ModesView = ({ onDone }: ModesViewProps) => {
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [showImportDialog, setShowImportDialog] = useState(false)
const [exportError, setExportError] = useState<string | null>(null)
const [importError, setImportError] = useState<string | null>(null)
const [hasRulesToExport, setHasRulesToExport] = useState<Record<string, boolean>>({})
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [modeToDelete, setModeToDelete] = useState<{
@ -449,17 +452,20 @@ const ModesView = ({ onDone }: ModesViewProps) => {
setIsExporting(false)
if (!message.success) {
// Show error message
console.error("Failed to export mode:", message.error)
setExportError(message.error || t("prompts:exportMode.error"))
// Clear error after 5 seconds
setTimeout(() => setExportError(null), 5000)
}
} else if (message.type === "importModeResult") {
setIsImporting(false)
setShowImportDialog(false)
if (!message.success) {
// Only log error if it's not a cancellation
// Only show error if it's not a cancellation
if (message.error !== "cancelled") {
console.error("Failed to import mode:", message.error)
setImportError(message.error || t("prompts:importMode.error"))
// Clear error after 5 seconds
setTimeout(() => setImportError(null), 5000)
}
}
} else if (message.type === "checkRulesDirectoryResult") {
@ -483,7 +489,7 @@ const ModesView = ({ onDone }: ModesViewProps) => {
window.addEventListener("message", handler)
return () => window.removeEventListener("message", handler)
}, []) // Empty dependency array - only register once
}, [t]) // Add t to dependency array
const handleAgentReset = (
modeSlug: string,
@ -1203,6 +1209,7 @@ const ModesView = ({ onDone }: ModesViewProps) => {
const currentMode = getCurrentMode()
if (currentMode?.slug && !isExporting) {
setIsExporting(true)
setExportError(null)
vscode.postMessage({
type: "exportMode",
slug: currentMode.slug,
@ -1219,7 +1226,10 @@ const ModesView = ({ onDone }: ModesViewProps) => {
{/* Import button - always visible */}
<Button
variant="default"
onClick={() => setShowImportDialog(true)}
onClick={() => {
setImportError(null)
setShowImportDialog(true)
}}
disabled={isImporting}
title={t("prompts:modes.importMode")}
data-testid="import-mode-button">
@ -1565,63 +1575,40 @@ const ModesView = ({ onDone }: ModesViewProps) => {
)}
{/* Import Mode Dialog */}
{showImportDialog && (
<div className="fixed inset-0 flex items-center justify-center bg-black/50 z-[1000]">
<div className="bg-vscode-editor-background border border-vscode-editor-lineHighlightBorder rounded-lg shadow-lg p-6 max-w-md w-full">
<h3 className="text-lg font-semibold mb-4">{t("prompts:modes.importMode")}</h3>
<p className="text-sm text-vscode-descriptionForeground mb-4">
{t("prompts:importMode.selectLevel")}
</p>
<div className="space-y-3 mb-6">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
name="importLevel"
value="project"
className="mt-1"
defaultChecked
/>
<div>
<div className="font-medium">{t("prompts:importMode.project.label")}</div>
<div className="text-xs text-vscode-descriptionForeground">
{t("prompts:importMode.project.description")}
</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input type="radio" name="importLevel" value="global" className="mt-1" />
<div>
<div className="font-medium">{t("prompts:importMode.global.label")}</div>
<div className="text-xs text-vscode-descriptionForeground">
{t("prompts:importMode.global.description")}
</div>
</div>
</label>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setShowImportDialog(false)}>
{t("prompts:createModeDialog.buttons.cancel")}
</Button>
<Button
variant="default"
onClick={() => {
if (!isImporting) {
const selectedLevel = (
document.querySelector(
'input[name="importLevel"]:checked',
) as HTMLInputElement
)?.value as "global" | "project"
setIsImporting(true)
vscode.postMessage({
type: "importMode",
source: selectedLevel || "project",
})
}
}}
disabled={isImporting}>
{isImporting ? t("prompts:importMode.importing") : t("prompts:importMode.import")}
</Button>
<ImportModeDialog
isOpen={showImportDialog}
onClose={() => setShowImportDialog(false)}
onImport={(source) => {
setIsImporting(true)
vscode.postMessage({
type: "importMode",
source,
})
}}
isImporting={isImporting}
/>
{/* Error notifications */}
{(exportError || importError) && (
<div className="fixed bottom-4 right-4 max-w-sm bg-vscode-notifications-background border border-vscode-notifications-border rounded-md shadow-lg p-4 z-[1001]">
<div className="flex items-start gap-2">
<span className="codicon codicon-error text-vscode-errorForeground flex-shrink-0 mt-0.5"></span>
<div className="flex-1">
<div className="text-sm font-medium text-vscode-notifications-foreground">
{exportError ? t("prompts:exportMode.errorTitle") : t("prompts:importMode.errorTitle")}
</div>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{exportError || importError}
</div>
</div>
<button
onClick={() => {
setExportError(null)
setImportError(null)
}}
className="text-vscode-icon-foreground hover:text-vscode-foreground">
<X className="h-4 w-4" />
</button>
</div>
</div>
)}