Skip to content

Commit aef8067

Browse files
committed
feat: add export/import buttons to mode selector popover
- Add export button for each mode in the popover list - Add import button in the popover header - Implement export/import handlers with loading states - Add inline import dialog for selecting global/project level - Reuse existing backend message handlers for export/import functionality Fixes #6320
1 parent 7b756e3 commit aef8067

File tree

1 file changed

+251
-106
lines changed

1 file changed

+251
-106
lines changed

webview-ui/src/components/chat/ModeSelector.tsx

Lines changed: 251 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import React from "react"
2-
import { ChevronUp, Check, X } from "lucide-react"
2+
import { ChevronUp, Check, X, Upload, Download } from "lucide-react"
33
import { cn } from "@/lib/utils"
44
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
5-
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
5+
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip, Button } from "@/components/ui"
66
import { IconButton } from "./IconButton"
77
import { vscode } from "@/utils/vscode"
88
import { useExtensionState } from "@/context/ExtensionStateContext"
@@ -46,6 +46,11 @@ export const ModeSelector = ({
4646
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
4747
const { t } = useAppTranslation()
4848

49+
// Export/Import state
50+
const [isExporting, setIsExporting] = React.useState<string | null>(null)
51+
const [isImporting, setIsImporting] = React.useState(false)
52+
const [showImportDialog, setShowImportDialog] = React.useState(false)
53+
4954
const trackModeSelectorOpened = React.useCallback(() => {
5055
// Track telemetry every time the mode selector is opened
5156
telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED)
@@ -146,6 +151,56 @@ export const ModeSelector = ({
146151
[trackModeSelectorOpened],
147152
)
148153

154+
// Handle export mode
155+
const handleExportMode = React.useCallback(
156+
(slug: string) => {
157+
if (!isExporting) {
158+
setIsExporting(slug)
159+
vscode.postMessage({
160+
type: "exportMode",
161+
slug: slug,
162+
})
163+
}
164+
},
165+
[isExporting],
166+
)
167+
168+
// Handle import mode
169+
const handleImportMode = React.useCallback(
170+
(source: "global" | "project") => {
171+
if (!isImporting) {
172+
setIsImporting(true)
173+
vscode.postMessage({
174+
type: "importMode",
175+
source: source,
176+
})
177+
}
178+
},
179+
[isImporting],
180+
)
181+
182+
// Listen for export/import results
183+
React.useEffect(() => {
184+
const handler = (event: MessageEvent) => {
185+
const message = event.data
186+
if (message.type === "exportModeResult") {
187+
setIsExporting(null)
188+
if (!message.success) {
189+
console.error("Failed to export mode:", message.error)
190+
}
191+
} else if (message.type === "importModeResult") {
192+
setIsImporting(false)
193+
setShowImportDialog(false)
194+
if (!message.success && message.error !== "cancelled") {
195+
console.error("Failed to import mode:", message.error)
196+
}
197+
}
198+
}
199+
200+
window.addEventListener("message", handler)
201+
return () => window.removeEventListener("message", handler)
202+
}, [])
203+
149204
// Auto-focus search input when popover opens
150205
React.useEffect(() => {
151206
if (open && searchInputRef.current) {
@@ -181,123 +236,213 @@ export const ModeSelector = ({
181236
)
182237

183238
return (
184-
<Popover open={open} onOpenChange={onOpenChange} data-testid="mode-selector-root">
185-
{title ? <StandardTooltip content={title}>{trigger}</StandardTooltip> : trigger}
186-
187-
<PopoverContent
188-
align="start"
189-
sideOffset={4}
190-
container={portalContainer}
191-
className="p-0 overflow-hidden min-w-80 max-w-9/10">
192-
<div className="flex flex-col w-full">
193-
{/* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */}
194-
{showSearch ? (
195-
<div className="relative p-2 border-b border-vscode-dropdown-border">
196-
<input
197-
aria-label="Search modes"
198-
ref={searchInputRef}
199-
value={searchValue}
200-
onChange={(e) => setSearchValue(e.target.value)}
201-
placeholder={t("chat:modeSelector.searchPlaceholder")}
202-
className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0"
203-
data-testid="mode-search-input"
204-
/>
205-
{searchValue.length > 0 && (
206-
<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
207-
<X
208-
className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
209-
onClick={onClearSearch}
210-
/>
211-
</div>
212-
)}
213-
</div>
214-
) : (
215-
<div className="p-3 border-b border-vscode-dropdown-border">
216-
<p className="m-0 text-xs text-vscode-descriptionForeground">{instructionText}</p>
217-
</div>
218-
)}
239+
<>
240+
<Popover open={open} onOpenChange={onOpenChange} data-testid="mode-selector-root">
241+
{title ? <StandardTooltip content={title}>{trigger}</StandardTooltip> : trigger}
219242

220-
{/* Mode List */}
221-
<div className="max-h-[300px] overflow-y-auto">
222-
{filteredModes.length === 0 && searchValue ? (
223-
<div className="py-2 px-3 text-sm text-vscode-foreground/70">
224-
{t("chat:modeSelector.noResults")}
243+
<PopoverContent
244+
align="start"
245+
sideOffset={4}
246+
container={portalContainer}
247+
className="p-0 overflow-hidden min-w-80 max-w-9/10">
248+
<div className="flex flex-col w-full">
249+
{/* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */}
250+
{showSearch ? (
251+
<div className="relative p-2 border-b border-vscode-dropdown-border">
252+
<input
253+
aria-label="Search modes"
254+
ref={searchInputRef}
255+
value={searchValue}
256+
onChange={(e) => setSearchValue(e.target.value)}
257+
placeholder={t("chat:modeSelector.searchPlaceholder")}
258+
className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0"
259+
data-testid="mode-search-input"
260+
/>
261+
{searchValue.length > 0 && (
262+
<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
263+
<X
264+
className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
265+
onClick={onClearSearch}
266+
/>
267+
</div>
268+
)}
225269
</div>
226270
) : (
227-
<div className="py-1">
228-
{filteredModes.map((mode) => (
229-
<div
230-
key={mode.slug}
231-
onClick={() => handleSelect(mode.slug)}
232-
className={cn(
233-
"px-3 py-1.5 text-sm cursor-pointer flex items-center",
234-
"hover:bg-vscode-list-hoverBackground",
235-
mode.slug === value
236-
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
237-
: "",
238-
)}
239-
data-testid="mode-selector-item">
240-
<div className="flex-1 min-w-0">
241-
<div className="font-bold truncate">{mode.name}</div>
242-
{mode.description && (
243-
<div className="text-xs text-vscode-descriptionForeground truncate">
244-
{mode.description}
245-
</div>
271+
<div className="p-3 border-b border-vscode-dropdown-border">
272+
<p className="m-0 text-xs text-vscode-descriptionForeground">{instructionText}</p>
273+
</div>
274+
)}
275+
276+
{/* Mode List */}
277+
<div className="max-h-[300px] overflow-y-auto">
278+
{filteredModes.length === 0 && searchValue ? (
279+
<div className="py-2 px-3 text-sm text-vscode-foreground/70">
280+
{t("chat:modeSelector.noResults")}
281+
</div>
282+
) : (
283+
<div className="py-1">
284+
{filteredModes.map((mode) => (
285+
<div
286+
key={mode.slug}
287+
className={cn(
288+
"px-3 py-1.5 text-sm flex items-center group",
289+
"hover:bg-vscode-list-hoverBackground",
290+
mode.slug === value
291+
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
292+
: "",
246293
)}
294+
data-testid="mode-selector-item">
295+
<div
296+
className="flex-1 min-w-0 cursor-pointer"
297+
onClick={() => handleSelect(mode.slug)}>
298+
<div className="font-bold truncate">{mode.name}</div>
299+
{mode.description && (
300+
<div className="text-xs text-vscode-descriptionForeground truncate">
301+
{mode.description}
302+
</div>
303+
)}
304+
</div>
305+
<div className="flex items-center gap-1 ml-2">
306+
{mode.slug === value && <Check className="size-4 p-0.5" />}
307+
<StandardTooltip content={t("prompts:exportMode.title")}>
308+
<Button
309+
variant="ghost"
310+
size="icon"
311+
className="opacity-0 group-hover:opacity-100 transition-opacity h-5 w-5"
312+
onClick={(e) => {
313+
e.stopPropagation()
314+
handleExportMode(mode.slug)
315+
}}
316+
disabled={isExporting === mode.slug}>
317+
{isExporting === mode.slug ? (
318+
<span className="codicon codicon-loading codicon-modifier-spin text-xs" />
319+
) : (
320+
<Upload className="h-3 w-3" />
321+
)}
322+
</Button>
323+
</StandardTooltip>
324+
</div>
247325
</div>
248-
{mode.slug === value && <Check className="ml-auto size-4 p-0.5" />}
249-
</div>
250-
))}
326+
))}
327+
</div>
328+
)}
329+
</div>
330+
331+
{/* Bottom bar with buttons on left and title on right */}
332+
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
333+
<div className="flex flex-row gap-1">
334+
<StandardTooltip content={t("prompts:modes.importMode")}>
335+
<Button
336+
variant="ghost"
337+
size="icon"
338+
onClick={() => setShowImportDialog(true)}
339+
disabled={isImporting}
340+
className="h-6 w-6">
341+
{isImporting ? (
342+
<span className="codicon codicon-loading codicon-modifier-spin text-xs" />
343+
) : (
344+
<Download className="h-3.5 w-3.5" />
345+
)}
346+
</Button>
347+
</StandardTooltip>
348+
<IconButton
349+
iconClass="codicon-extensions"
350+
title={t("chat:modeSelector.marketplace")}
351+
onClick={() => {
352+
window.postMessage(
353+
{
354+
type: "action",
355+
action: "marketplaceButtonClicked",
356+
values: { marketplaceTab: "mode" },
357+
},
358+
"*",
359+
)
360+
setOpen(false)
361+
}}
362+
/>
363+
<IconButton
364+
iconClass="codicon-settings-gear"
365+
title={t("chat:modeSelector.settings")}
366+
onClick={() => {
367+
vscode.postMessage({
368+
type: "switchTab",
369+
tab: "modes",
370+
})
371+
setOpen(false)
372+
}}
373+
/>
251374
</div>
252-
)}
375+
376+
{/* Info icon and title on the right - only show info icon when search bar is visible */}
377+
<div className="flex items-center gap-1 pr-1">
378+
{showSearch && (
379+
<StandardTooltip content={instructionText}>
380+
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
381+
</StandardTooltip>
382+
)}
383+
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
384+
{t("chat:modeSelector.title")}
385+
</h4>
386+
</div>
387+
</div>
253388
</div>
389+
</PopoverContent>
390+
</Popover>
254391

255-
{/* Bottom bar with buttons on left and title on right */}
256-
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
257-
<div className="flex flex-row gap-1">
258-
<IconButton
259-
iconClass="codicon-extensions"
260-
title={t("chat:modeSelector.marketplace")}
261-
onClick={() => {
262-
window.postMessage(
263-
{
264-
type: "action",
265-
action: "marketplaceButtonClicked",
266-
values: { marketplaceTab: "mode" },
267-
},
268-
"*",
269-
)
270-
setOpen(false)
271-
}}
272-
/>
273-
<IconButton
274-
iconClass="codicon-settings-gear"
275-
title={t("chat:modeSelector.settings")}
392+
{/* Import Mode Dialog */}
393+
{showImportDialog && (
394+
<div className="fixed inset-0 flex items-center justify-center bg-black/50 z-[1000]">
395+
<div className="bg-vscode-editor-background border border-vscode-editor-lineHighlightBorder rounded-lg shadow-lg p-6 max-w-md w-full">
396+
<h3 className="text-lg font-semibold mb-4">{t("prompts:modes.importMode")}</h3>
397+
<p className="text-sm text-vscode-descriptionForeground mb-4">
398+
{t("prompts:importMode.selectLevel")}
399+
</p>
400+
<div className="space-y-3 mb-6">
401+
<label className="flex items-start gap-2 cursor-pointer">
402+
<input
403+
type="radio"
404+
name="importLevel"
405+
value="project"
406+
className="mt-1"
407+
defaultChecked
408+
/>
409+
<div>
410+
<div className="font-medium">{t("prompts:importMode.project.label")}</div>
411+
<div className="text-xs text-vscode-descriptionForeground">
412+
{t("prompts:importMode.project.description")}
413+
</div>
414+
</div>
415+
</label>
416+
<label className="flex items-start gap-2 cursor-pointer">
417+
<input type="radio" name="importLevel" value="global" className="mt-1" />
418+
<div>
419+
<div className="font-medium">{t("prompts:importMode.global.label")}</div>
420+
<div className="text-xs text-vscode-descriptionForeground">
421+
{t("prompts:importMode.global.description")}
422+
</div>
423+
</div>
424+
</label>
425+
</div>
426+
<div className="flex justify-end gap-2">
427+
<Button variant="secondary" onClick={() => setShowImportDialog(false)}>
428+
{t("prompts:createModeDialog.buttons.cancel")}
429+
</Button>
430+
<Button
431+
variant="default"
276432
onClick={() => {
277-
vscode.postMessage({
278-
type: "switchTab",
279-
tab: "modes",
280-
})
281-
setOpen(false)
433+
const selectedLevel = (
434+
document.querySelector('input[name="importLevel"]:checked') as HTMLInputElement
435+
)?.value as "global" | "project"
436+
handleImportMode(selectedLevel || "project")
282437
}}
283-
/>
284-
</div>
285-
286-
{/* Info icon and title on the right - only show info icon when search bar is visible */}
287-
<div className="flex items-center gap-1 pr-1">
288-
{showSearch && (
289-
<StandardTooltip content={instructionText}>
290-
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
291-
</StandardTooltip>
292-
)}
293-
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
294-
{t("chat:modeSelector.title")}
295-
</h4>
438+
disabled={isImporting}>
439+
{isImporting ? t("prompts:importMode.importing") : t("prompts:importMode.import")}
440+
</Button>
296441
</div>
297442
</div>
298443
</div>
299-
</PopoverContent>
300-
</Popover>
444+
)}
445+
</>
301446
)
302447
}
303448

0 commit comments

Comments
 (0)