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

This commit is contained in:
Hannes Rudolph 2026-01-21 19:34:48 -07:00 committed by GitHub
parent 3f332d8e2b
commit 5e0bb5af26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 143 additions and 38 deletions

View file

@ -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.

View file

@ -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", () => {

View file

@ -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()

View file

@ -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. */

View file

@ -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")

View file

@ -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 {

View file

@ -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
src/utils/export.ts Normal file
View file

@ -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)
}