Bläddra i källkod

refactor: unify export path logic and default to Downloads (#10882)

Hannes Rudolph 3 veckor sedan
förälder
incheckning
5e0bb5af26

+ 3 - 0
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.

+ 2 - 1
src/core/config/__tests__/importExport.spec.ts

@@ -117,6 +117,7 @@ describe("importExport", () => {
 			setValue: vi.fn(),
 			export: vi.fn().mockImplementation(() => Promise.resolve({})),
 			setProviderSettings: vi.fn(),
+			getValue: vi.fn(),
 		} as unknown as ReturnType<typeof vi.mocked<ContextProxy>>
 
 		mockCustomModesManager = { updateCustomMode: vi.fn() } as unknown as ReturnType<
@@ -693,7 +694,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", () => {

+ 9 - 1
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()

+ 12 - 2
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. */

+ 37 - 21
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!
@@ -2122,25 +2148,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({
@@ -2153,7 +2169,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")

+ 14 - 4
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<vscode.Uri | undefined> {
+	// 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 {

+ 5 - 9
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<vscode.Uri | undefined> {
 	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
 	}
 }

+ 61 - 0
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<void>
+}
+
+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)
+}