diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 2eaf5f59816..8c6a4a70fbe 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -197,6 +197,9 @@ export const globalSettingsSchema = z.object({ hasOpenedModeSelector: z.boolean().optional(), lastModeExportPath: z.string().optional(), lastModeImportPath: z.string().optional(), + lastSettingsExportPath: z.string().optional(), + lastTaskExportPath: z.string().optional(), + lastImageSavePath: z.string().optional(), /** * Path to worktree to auto-open after switching workspaces. diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 7a1247efe80..9abcb14bf4b 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -126,6 +126,7 @@ describe("importExport", () => { setValue: vi.fn(), export: vi.fn().mockImplementation(() => Promise.resolve({})), setProviderSettings: vi.fn(), + getValue: vi.fn(), } as unknown as ReturnType> mockCustomModesManager = { updateCustomMode: vi.fn() } as unknown as ReturnType< @@ -703,7 +704,7 @@ describe("importExport", () => { defaultUri: expect.anything(), }) - expect(vscode.Uri.file).toHaveBeenCalledWith(path.join("/mock/home", "Documents", "roo-code-settings.json")) + expect(vscode.Uri.file).toHaveBeenCalledWith(path.join("/mock/home", "Downloads", "roo-code-settings.json")) }) describe("codebase indexing export", () => { diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index c3d6f9c2159..de3119e0c90 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -12,6 +12,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { ProviderSettingsManager, providerProfilesSchema } from "./ProviderSettingsManager" import { ContextProxy } from "./ContextProxy" import { CustomModesManager } from "./CustomModesManager" +import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { t } from "../../i18n" export type ImportOptions = { @@ -143,15 +144,22 @@ export const importSettingsFromFile = async ( } export const exportSettings = async ({ providerSettingsManager, contextProxy }: ExportOptions) => { + const defaultUri = await resolveDefaultSaveUri(contextProxy, "lastSettingsExportPath", "roo-code-settings.json", { + useWorkspace: false, + fallbackDir: path.join(os.homedir(), "Downloads"), + }) + const uri = await vscode.window.showSaveDialog({ filters: { JSON: ["json"] }, - defaultUri: vscode.Uri.file(path.join(os.homedir(), "Documents", "roo-code-settings.json")), + defaultUri, }) if (!uri) { return } + await saveLastExportPath(contextProxy, "lastSettingsExportPath", uri) + try { const providerProfiles = await providerSettingsManager.export() const globalSettings = await contextProxy.export() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 50edb95df91..eb313ae80d1 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -63,7 +63,8 @@ import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels" import { ProfileValidator } from "../../shared/ProfileValidator" import { Terminal } from "../../integrations/terminal/Terminal" -import { downloadTask } from "../../integrations/misc/export-markdown" +import { downloadTask, getTaskFileName } from "../../integrations/misc/export-markdown" +import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { getTheme } from "../../integrations/theme/getTheme" import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" @@ -1724,7 +1725,16 @@ export class ClineProvider async exportTaskWithId(id: string) { const { historyItem, apiConversationHistory } = await this.getTaskWithId(id) - await downloadTask(historyItem.ts, apiConversationHistory) + const fileName = getTaskFileName(historyItem.ts) + const defaultUri = await resolveDefaultSaveUri(this.contextProxy, "lastTaskExportPath", fileName, { + useWorkspace: false, + fallbackDir: path.join(os.homedir(), "Downloads"), + }) + const saveUri = await downloadTask(historyItem.ts, apiConversationHistory, defaultUri) + + if (saveUri) { + await saveLastExportPath(this.contextProxy, "lastTaskExportPath", saveUri) + } } /* Condenses a task's message history to use fewer tokens. */ diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 2af791b93ee..638e3a51222 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -60,6 +60,7 @@ import { Mode, defaultModeSlug } from "../../shared/modes" import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" import { GetModelsOptions } from "../../shared/api" import { generateSystemPrompt } from "./generateSystemPrompt" +import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { getCommand } from "../../utils/commands" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) @@ -1140,7 +1141,32 @@ export const webviewMessageHandler = async ( openImage(message.text!, { values: message.values }) break case "saveImage": - saveImage(message.dataUri!) + if (message.dataUri) { + const matches = message.dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/) + if (!matches) { + // Let saveImage handle invalid URI error + saveImage(message.dataUri, vscode.Uri.file("")) + break + } + const format = matches[1] + const defaultFileName = `img_${Date.now()}.${format}` + + const defaultUri = await resolveDefaultSaveUri( + provider.contextProxy, + "lastImageSavePath", + defaultFileName, + { + useWorkspace: false, + fallbackDir: path.join(os.homedir(), "Downloads"), + }, + ) + + const savedUri = await saveImage(message.dataUri, defaultUri) + + if (savedUri) { + await saveLastExportPath(provider.contextProxy, "lastImageSavePath", savedUri) + } + } break case "openFile": let filePath: string = message.text! @@ -2132,25 +2158,15 @@ export const webviewMessageHandler = async ( const result = await provider.customModesManager.exportModeWithRules(message.slug, customPrompt) if (result.success && result.yaml) { - // Get last used directory for export - const lastExportPath = getGlobalState("lastModeExportPath") - let defaultUri: vscode.Uri - - if (lastExportPath) { - // Use the directory from the last export - const lastDir = path.dirname(lastExportPath) - defaultUri = vscode.Uri.file(path.join(lastDir, `${message.slug}-export.yaml`)) - } else { - // Default to workspace or home directory - const workspaceFolders = vscode.workspace.workspaceFolders - if (workspaceFolders && workspaceFolders.length > 0) { - defaultUri = vscode.Uri.file( - path.join(workspaceFolders[0].uri.fsPath, `${message.slug}-export.yaml`), - ) - } else { - defaultUri = vscode.Uri.file(`${message.slug}-export.yaml`) - } - } + const defaultUri = await resolveDefaultSaveUri( + provider.contextProxy, + "lastModeExportPath", + `${message.slug}-export.yaml`, + { + useWorkspace: true, + fallbackDir: path.join(os.homedir(), "Downloads"), + }, + ) // Show save dialog const saveUri = await vscode.window.showSaveDialog({ @@ -2163,7 +2179,7 @@ export const webviewMessageHandler = async ( if (saveUri && result.yaml) { // Save the directory for next time - await updateGlobalState("lastModeExportPath", saveUri.fsPath) + await saveLastExportPath(provider.contextProxy, "lastModeExportPath", saveUri) // Write the file to the selected location await fs.writeFile(saveUri.fsPath, result.yaml, "utf-8") diff --git a/src/integrations/misc/export-markdown.ts b/src/integrations/misc/export-markdown.ts index f2c0cd7a38b..2c2207eda57 100644 --- a/src/integrations/misc/export-markdown.ts +++ b/src/integrations/misc/export-markdown.ts @@ -11,8 +11,7 @@ interface ReasoningBlock { type ExtendedContentBlock = Anthropic.Messages.ContentBlockParam | ReasoningBlock -export async function downloadTask(dateTs: number, conversationHistory: Anthropic.MessageParam[]) { - // File name +export function getTaskFileName(dateTs: number): string { const date = new Date(dateTs) const month = date.toLocaleString("en-US", { month: "short" }).toLowerCase() const day = date.getDate() @@ -23,7 +22,16 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi const ampm = hours >= 12 ? "pm" : "am" hours = hours % 12 hours = hours ? hours : 12 // the hour '0' should be '12' - const fileName = `roo_task_${month}-${day}-${year}_${hours}-${minutes}-${seconds}-${ampm}.md` + return `roo_task_${month}-${day}-${year}_${hours}-${minutes}-${seconds}-${ampm}.md` +} + +export async function downloadTask( + dateTs: number, + conversationHistory: Anthropic.MessageParam[], + defaultUri: vscode.Uri, +): Promise { + // File name + const fileName = getTaskFileName(dateTs) // Generate markdown const markdownContent = conversationHistory @@ -39,14 +47,16 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi // Prompt user for save location const saveUri = await vscode.window.showSaveDialog({ filters: { Markdown: ["md"] }, - defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", fileName)), + defaultUri, }) if (saveUri) { // Write content to the selected location await vscode.workspace.fs.writeFile(saveUri, Buffer.from(markdownContent)) vscode.window.showTextDocument(saveUri, { preview: true }) + return saveUri } + return undefined } export function formatContentBlockToMarkdown(block: ExtendedContentBlock): string { diff --git a/src/integrations/misc/image-handler.ts b/src/integrations/misc/image-handler.ts index 7a2e7da24c7..2f8af7afad0 100644 --- a/src/integrations/misc/image-handler.ts +++ b/src/integrations/misc/image-handler.ts @@ -90,21 +90,15 @@ export async function openImage(dataUriOrPath: string, options?: { values?: { ac } } -export async function saveImage(dataUri: string) { +export async function saveImage(dataUri: string, defaultUri: vscode.Uri): Promise { const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/) if (!matches) { vscode.window.showErrorMessage(t("common:errors.invalid_data_uri")) - return + return undefined } const [, format, base64Data] = matches const imageBuffer = Buffer.from(base64Data, "base64") - // Get workspace path or fallback to home directory - const workspacePath = getWorkspacePath() - const defaultPath = workspacePath || os.homedir() - const defaultFileName = `img_${Date.now()}.${format}` - const defaultUri = vscode.Uri.file(path.join(defaultPath, defaultFileName)) - // Show save dialog const saveUri = await vscode.window.showSaveDialog({ filters: { @@ -116,15 +110,17 @@ export async function saveImage(dataUri: string) { if (!saveUri) { // User cancelled the save dialog - return + return undefined } try { // Write the image to the selected location await vscode.workspace.fs.writeFile(saveUri, imageBuffer) vscode.window.showInformationMessage(t("common:info.image_saved", { path: saveUri.fsPath })) + return saveUri } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) vscode.window.showErrorMessage(t("common:errors.error_saving_image", { errorMessage })) + return undefined } } diff --git a/src/utils/export.ts b/src/utils/export.ts new file mode 100644 index 00000000000..84551f5c8e6 --- /dev/null +++ b/src/utils/export.ts @@ -0,0 +1,61 @@ +import * as vscode from "vscode" +import * as path from "path" + +export interface ExportContext { + getValue(key: string): any + setValue(key: string, value: any): Promise +} + +export interface ExportOptions { + /** + * Whether to consider the active workspace folder as a default location. + * Default: true + */ + useWorkspace?: boolean + /** + * Fallback directory if no previous path or workspace is available. + */ + fallbackDir?: string +} + +/** + * Resolves the default save URI for an export operation. + * Priorities: + * 1. Last used export path (if available) + * 2. Active workspace folder (if useWorkspace is true) + * 3. Fallback directory (e.g. Downloads or Documents) + * 4. Default to just the filename (user's home/cwd) + */ +export function resolveDefaultSaveUri( + context: ExportContext, + configKey: string, + fileName: string, + options: ExportOptions = {}, +): vscode.Uri { + const { useWorkspace = true, fallbackDir } = options + const lastExportPath = context.getValue(configKey) as string | undefined + + if (lastExportPath) { + // Use the directory from the last export + const lastDir = path.dirname(lastExportPath) + return vscode.Uri.file(path.join(lastDir, fileName)) + } else { + // Try workspace if enabled + const workspaceFolders = vscode.workspace.workspaceFolders + if (useWorkspace && workspaceFolders && workspaceFolders.length > 0) { + return vscode.Uri.file(path.join(workspaceFolders[0].uri.fsPath, fileName)) + } + + // Fallback + if (fallbackDir) { + return vscode.Uri.file(path.join(fallbackDir, fileName)) + } + + // Default to cwd/home + return vscode.Uri.file(fileName) + } +} + +export async function saveLastExportPath(context: ExportContext, configKey: string, uri: vscode.Uri) { + await context.setValue(configKey, uri.fsPath) +}