|
1 | 1 | import React from "react" |
2 | | -import { ChevronUp, Check, X } from "lucide-react" |
| 2 | +import { ChevronUp, Check, X, Upload, Download } from "lucide-react" |
3 | 3 | import { cn } from "@/lib/utils" |
4 | 4 | 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" |
6 | 6 | import { IconButton } from "./IconButton" |
7 | 7 | import { vscode } from "@/utils/vscode" |
8 | 8 | import { useExtensionState } from "@/context/ExtensionStateContext" |
@@ -46,6 +46,11 @@ export const ModeSelector = ({ |
46 | 46 | const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState() |
47 | 47 | const { t } = useAppTranslation() |
48 | 48 |
|
| 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 | + |
49 | 54 | const trackModeSelectorOpened = React.useCallback(() => { |
50 | 55 | // Track telemetry every time the mode selector is opened |
51 | 56 | telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED) |
@@ -146,6 +151,56 @@ export const ModeSelector = ({ |
146 | 151 | [trackModeSelectorOpened], |
147 | 152 | ) |
148 | 153 |
|
| 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 | + |
149 | 204 | // Auto-focus search input when popover opens |
150 | 205 | React.useEffect(() => { |
151 | 206 | if (open && searchInputRef.current) { |
@@ -181,123 +236,213 @@ export const ModeSelector = ({ |
181 | 236 | ) |
182 | 237 |
|
183 | 238 | 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} |
219 | 242 |
|
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 | + )} |
225 | 269 | </div> |
226 | 270 | ) : ( |
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 | + : "", |
246 | 293 | )} |
| 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> |
247 | 325 | </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 | + /> |
251 | 374 | </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> |
253 | 388 | </div> |
| 389 | + </PopoverContent> |
| 390 | + </Popover> |
254 | 391 |
|
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" |
276 | 432 | 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") |
282 | 437 | }} |
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> |
296 | 441 | </div> |
297 | 442 | </div> |
298 | 443 | </div> |
299 | | - </PopoverContent> |
300 | | - </Popover> |
| 444 | + )} |
| 445 | + </> |
301 | 446 | ) |
302 | 447 | } |
303 | 448 |
|
|
0 commit comments