mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
956891bc45
commit
642115c4b0
7 changed files with 712 additions and 204 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
96
webview-ui/src/components/chat/ModeSelectorFooter.tsx
Normal file
96
webview-ui/src/components/chat/ModeSelectorFooter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
64
webview-ui/src/components/common/ImportModeDialog.tsx
Normal file
64
webview-ui/src/components/common/ImportModeDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue