Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/core/config/__tests__/importExport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.mocked<ContextProxy>>

mockCustomModesManager = { updateCustomMode: vi.fn() } as unknown as ReturnType<
Expand Down Expand Up @@ -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", () => {
Expand Down
10 changes: 9 additions & 1 deletion src/core/config/importExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 12 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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. */
Expand Down
58 changes: 37 additions & 21 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -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({
Expand All @@ -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")
Expand Down
18 changes: 14 additions & 4 deletions src/integrations/misc/export-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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 {
Expand Down
14 changes: 5 additions & 9 deletions src/integrations/misc/image-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 changes: 61 additions & 0 deletions src/utils/export.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading