From 2dc1027cf64b09875d8d831091885c5ea266e522 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Thu, 24 Jul 2025 15:11:53 -0400 Subject: [PATCH 01/11] Add `fileExists()` utility --- editors/code/src/utils.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/editors/code/src/utils.ts b/editors/code/src/utils.ts index 135da1f2..c3ba2a21 100644 --- a/editors/code/src/utils.ts +++ b/editors/code/src/utils.ts @@ -1,3 +1,4 @@ +import * as vscode from "vscode"; import * as url from "url"; import * as path from "path"; @@ -11,3 +12,20 @@ export function normalizePath(file: string): string { } return path.normalize(file); } + +/** + * Determine whether or not a file exists + * + * `vscode.workspace.fs.*` does not provide a way to check if a Uri exists or not, + * so this is supposedly the next best way to do so. + * + * This also works on directories. + */ +export async function fileExists(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch { + return false; + } +} From 736ac127f2080483ad0b61855ba5e850c68e6243 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Thu, 24 Jul 2025 15:15:59 -0400 Subject: [PATCH 02/11] Make `workspaceFolderFormatting()` callable on its own Also make some helpers usable elsewhere --- .../command/workspace-folder-formatting.ts | 227 +++++------------- editors/code/src/commands.ts | 4 +- editors/code/src/workspace.ts | 132 ++++++++++ 3 files changed, 188 insertions(+), 175 deletions(-) diff --git a/editors/code/src/command/workspace-folder-formatting.ts b/editors/code/src/command/workspace-folder-formatting.ts index c4f61b78..45ddf74e 100644 --- a/editors/code/src/command/workspace-folder-formatting.ts +++ b/editors/code/src/command/workspace-folder-formatting.ts @@ -3,16 +3,14 @@ import * as vscode from "vscode"; import { Cmd, Ctx } from "../context"; import * as output from "../output"; import { isError, isResult, runCommand } from "../process"; +import { + saveAllDirtyWorkspaceTextDocuments, + selectWorkspaceFolder, +} from "../workspace"; /** * Format a workspace folder * - * # Workspace folder selection - * - * - If 0 workspace folders are open, errors - * - If 1 workspace folder is open, automatically uses it - * - If >1 workspace folders are open, asks the user to choose - * * # Tab saving * * Because we use the embedded Air CLI to perform the formatting, we force all @@ -42,195 +40,78 @@ import { isError, isResult, runCommand } from "../process"; * `{root}/R/air.toml` that would handle any R files in your project. Instead, * we hope this isn't common enough to come up much in practice. */ -export function workspaceFolderFormatting(ctx: Ctx): Cmd { - return async () => { - const binaryPath = ctx.lsp.getBinaryPath(); - - const workspaceFolder = await selectWorkspaceFolder(); - if (!workspaceFolder) { - return; - } +export async function workspaceFolderFormatting( + workspaceFolder: vscode.WorkspaceFolder, + binaryPath: string, +) { + const allSaved = await saveAllDirtyWorkspaceTextDocuments(workspaceFolder); + if (!allSaved) { + return; + } - const allSaved = - await saveAllDirtyWorkspaceTextDocuments(workspaceFolder); - if (!allSaved) { - return; - } + const workspaceFolderPath = workspaceFolder.uri.fsPath; - const workspaceFolderPath = workspaceFolder.uri.fsPath; + // i.e., `air format {workspaceFolderPath} --no-color` + const args = ["format", workspaceFolderPath, "--no-color"]; - // i.e., `air format {workspaceFolderPath} --no-color` - const args = ["format", workspaceFolderPath, "--no-color"]; + // This should not matter since the path is explicitly supplied, but better to be safe + const options = { + cwd: workspaceFolderPath, + }; - // This should not matter since the path is explicitly supplied, but better to be safe - const options = { - cwd: workspaceFolderPath, - }; + // Resolves when the spawned process closes or errors + const result = await runCommand(binaryPath, args, options); - // Resolves when the spawned process closes or errors - const result = await runCommand(binaryPath, args, options); + let anyErrors = false; - let anyErrors = false; + if (isError(result)) { + // Something went horribly wrong in the process spawning or shutdown process + anyErrors = true; + output.log( + `Errors occurred while formatting the ${workspaceFolder.name} workspace folder.\n${result.error.message}`, + ); + } - if (isError(result)) { - // Something went horribly wrong in the process spawning or shutdown process - anyErrors = true; + if (isResult(result)) { + if (result.code !== 0) { + // Air was able to run and exit, but we had an error along the way output.log( - `Errors occurred while formatting the ${workspaceFolder.name} workspace folder.\n${result.error.message}`, + `Errors occurred while formatting the ${workspaceFolder.name} workspace folder.\n${result.stderr}`, ); + anyErrors = true; } - - if (isResult(result)) { - if (result.code !== 0) { - // Air was able to run and exit, but we had an error along the way - output.log( - `Errors occurred while formatting the ${workspaceFolder.name} workspace folder.\n${result.stderr}`, - ); - anyErrors = true; - } - } - - if (anyErrors) { - const answer = await vscode.window.showInformationMessage( - `Errors occurred while formatting the ${workspaceFolder.name} workspace folder. View the logs?`, - { modal: true }, - "Yes", - "No", - ); - - if (answer === "Yes") { - output.show(); - } - - return; - } - - vscode.window.showInformationMessage( - `Successfully formatted the ${workspaceFolder.name} workspace folder.`, - ); - }; -} - -async function selectWorkspaceFolder(): Promise< - vscode.WorkspaceFolder | undefined -> { - const workspaceFolders = vscode.workspace.workspaceFolders; - - if (!workspaceFolders || workspaceFolders.length === 0) { - vscode.window.showErrorMessage( - "You must be inside a workspace to format a workspace folder.", - ); - return undefined; - } - - if (workspaceFolders.length === 1) { - return workspaceFolders[0]; } - // Let the user select a workspace folder if >1 are open, may be - // `undefined` if user bails from quick pick! - const workspaceFolder = - await selectWorkspaceFolderFromQuickPick(workspaceFolders); - - return workspaceFolder; -} - -async function selectWorkspaceFolderFromQuickPick( - workspaceFolders: readonly vscode.WorkspaceFolder[], -): Promise { - // Show the workspace names - const workspaceFolderNames = workspaceFolders.map( - (workspaceFolder) => workspaceFolder.name, - ); - - const workspaceFolderName = await vscode.window.showQuickPick( - workspaceFolderNames, - { - canPickMany: false, - title: "Which workspace folder should be formatted?", - }, - ); - - if (!workspaceFolderName) { - // User bailed from the quick pick - return undefined; - } + if (anyErrors) { + const answer = await vscode.window.showInformationMessage( + `Errors occurred while formatting the ${workspaceFolder.name} workspace folder. View the logs?`, + { modal: true }, + "Yes", + "No", + ); - // Match selected name back to the workspace folder - for (let workspaceFolder of workspaceFolders) { - if (workspaceFolder.name === workspaceFolderName) { - return workspaceFolder; + if (answer === "Yes") { + output.show(); } - } - - // Should never get here - output.log( - `Matched a workspace folder name, but unexpectedly can't find corresponding workspace folder. Folder name: ${workspaceFolderName}.`, - ); - return undefined; -} - -/** - * Save all open dirty editor tabs relevant to the workspace folder - * - * - Filters to only tabs living under the chosen workspace folder - * - Asks the user if they are okay with us saving the editor tabs - */ -async function saveAllDirtyWorkspaceTextDocuments( - workspaceFolder: vscode.WorkspaceFolder, -): Promise { - const textDocuments = dirtyWorkspaceTextDocuments(workspaceFolder); - if (textDocuments.length === 0) { - // Nothing to save! - return true; + return; } - // Ask the user if we can save them - const answer = await vscode.window.showInformationMessage( - `All editors within the ${workspaceFolder.name} workspace folder must be saved before formatting. Proceed with saving these editors?`, - { modal: true }, - "Yes", - "No", + vscode.window.showInformationMessage( + `Successfully formatted the ${workspaceFolder.name} workspace folder.`, ); - - if (answer !== "Yes") { - // User said `"No"` or bailed from the menu - return false; - } - - // Save all documents, and ensure that all successfully saved - const savedPromises = textDocuments.map((textDocument) => - textDocument.save(), - ); - const saved = await Promise.all(savedPromises); - return saved.every((save) => save); } -function dirtyWorkspaceTextDocuments( - workspaceFolder: vscode.WorkspaceFolder, -): vscode.TextDocument[] { - return vscode.workspace.textDocuments.filter((document) => { - if (document.isClosed) { - // Not actually synchonized. This document will be refreshed when the document is reopened. - return false; - } - - if (!document.isDirty) { - // Nothing to do - return false; - } +export function workspaceFolderFormattingCallback(ctx: Ctx): Cmd { + return async () => { + const workspaceFolder = await selectWorkspaceFolder(); - if (document.isUntitled) { - // These aren't part of the workspace folder - return false; + if (!workspaceFolder) { + return; } - if (!document.uri.fsPath.startsWith(workspaceFolder.uri.fsPath)) { - // The document must live "under" the chosen workspace folder for us to care about it - return false; - } + const binaryPath = ctx.lsp.getBinaryPath(); - return true; - }); + await workspaceFolderFormatting(workspaceFolder, binaryPath); + }; } diff --git a/editors/code/src/commands.ts b/editors/code/src/commands.ts index 11d8a2f7..c085b810 100644 --- a/editors/code/src/commands.ts +++ b/editors/code/src/commands.ts @@ -5,7 +5,7 @@ import AdmZip from "adm-zip"; import { Cmd, Ctx } from "./context"; import { viewFileUsingTextDocumentContentProvider } from "./request/viewFile"; import { VIEW_FILE } from "./request/viewFile"; -import { workspaceFolderFormatting } from "./command/workspace-folder-formatting"; +import { workspaceFolderFormattingCallback } from "./command/workspace-folder-formatting"; export function registerCommands(ctx: Ctx) { ctx.extension.subscriptions.push( @@ -18,7 +18,7 @@ export function registerCommands(ctx: Ctx) { ctx.extension.subscriptions.push( vscode.commands.registerCommand( "air.workspaceFolderFormatting", - workspaceFolderFormatting(ctx), + workspaceFolderFormattingCallback(ctx), ), ); diff --git a/editors/code/src/workspace.ts b/editors/code/src/workspace.ts index d49b1678..4acd6fc2 100644 --- a/editors/code/src/workspace.ts +++ b/editors/code/src/workspace.ts @@ -1,6 +1,7 @@ import path from "path"; import * as vscode from "vscode"; import * as fs from "fs-extra"; +import * as output from "./output"; /* * Locate the "root" workspace folder @@ -59,3 +60,134 @@ export async function getRootWorkspaceFolder(): Promise function getWorkspaceFolders(): readonly vscode.WorkspaceFolder[] { return vscode.workspace.workspaceFolders ?? []; } + +/* + * Select a workspace folder to act on + * + * - If 0 workspaces are open, returns `undefined` with an error message. + * - If 1 workspace is open, returns it. + * - If >1 workspaces are open, shows the user a quick-pick menu to select their preference. + */ +export async function selectWorkspaceFolder(): Promise< + vscode.WorkspaceFolder | undefined +> { + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage( + "You must be inside a workspace to format a workspace folder.", + ); + return undefined; + } + + if (workspaceFolders.length === 1) { + return workspaceFolders[0]; + } + + // Let the user select a workspace folder if >1 are open, may be + // `undefined` if user bails from quick pick! + const workspaceFolder = + await selectWorkspaceFolderFromQuickPick(workspaceFolders); + + return workspaceFolder; +} + +async function selectWorkspaceFolderFromQuickPick( + workspaceFolders: readonly vscode.WorkspaceFolder[], +): Promise { + // Show the workspace names + const workspaceFolderNames = workspaceFolders.map( + (workspaceFolder) => workspaceFolder.name, + ); + + const workspaceFolderName = await vscode.window.showQuickPick( + workspaceFolderNames, + { + canPickMany: false, + title: "Which workspace folder should be formatted?", + }, + ); + + if (!workspaceFolderName) { + // User bailed from the quick pick + return undefined; + } + + // Match selected name back to the workspace folder + for (let workspaceFolder of workspaceFolders) { + if (workspaceFolder.name === workspaceFolderName) { + return workspaceFolder; + } + } + + // Should never get here + output.log( + `Matched a workspace folder name, but unexpectedly can't find corresponding workspace folder. Folder name: ${workspaceFolderName}.`, + ); + return undefined; +} + +/** + * Save all open dirty editor tabs relevant to the workspace folder + * + * - Filters to only tabs living under the chosen workspace folder + * - Asks the user if they are okay with us saving the editor tabs + */ +export async function saveAllDirtyWorkspaceTextDocuments( + workspaceFolder: vscode.WorkspaceFolder, +): Promise { + const textDocuments = dirtyWorkspaceTextDocuments(workspaceFolder); + + if (textDocuments.length === 0) { + // Nothing to save! + return true; + } + + // Ask the user if we can save them + const answer = await vscode.window.showInformationMessage( + `All editors within the ${workspaceFolder.name} workspace folder must be saved first. Proceed with saving these editors?`, + { modal: true }, + "Yes", + "No", + ); + + if (answer !== "Yes") { + // User said `"No"` or bailed from the menu + return false; + } + + // Save all documents, and ensure that all successfully saved + const savedPromises = textDocuments.map((textDocument) => + textDocument.save(), + ); + const saved = await Promise.all(savedPromises); + return saved.every((save) => save); +} + +function dirtyWorkspaceTextDocuments( + workspaceFolder: vscode.WorkspaceFolder, +): vscode.TextDocument[] { + return vscode.workspace.textDocuments.filter((document) => { + if (document.isClosed) { + // Not actually synchonized. This document will be refreshed when the document is reopened. + return false; + } + + if (!document.isDirty) { + // Nothing to do + return false; + } + + if (document.isUntitled) { + // These aren't part of the workspace folder + return false; + } + + if (!document.uri.fsPath.startsWith(workspaceFolder.uri.fsPath)) { + // The document must live "under" the chosen workspace folder for us to care about it + return false; + } + + return true; + }); +} From ea0e0bcbd7cabe6ebfe85fbe0bd22f74defb3df3 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Thu, 24 Jul 2025 15:22:41 -0400 Subject: [PATCH 03/11] Implement `> Initialize Workspace Folder` --- editors/code/package.json | 5 + .../workspace-folder-initialization.ts | 206 ++++++++++++++++++ editors/code/src/commands.ts | 8 + 3 files changed, 219 insertions(+) create mode 100644 editors/code/src/command/workspace-folder-initialization.ts diff --git a/editors/code/package.json b/editors/code/package.json index c83918a9..f012c109 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -116,6 +116,11 @@ "category": "Air", "command": "air.restart" }, + { + "title": "Initialize Workspace Folder", + "category": "Air", + "command": "air.workspaceFolderInitialization" + }, { "title": "Format Workspace Folder", "category": "Air", diff --git a/editors/code/src/command/workspace-folder-initialization.ts b/editors/code/src/command/workspace-folder-initialization.ts new file mode 100644 index 00000000..6696d670 --- /dev/null +++ b/editors/code/src/command/workspace-folder-initialization.ts @@ -0,0 +1,206 @@ +import * as vscode from "vscode"; + +import { Cmd, Ctx } from "../context"; +import { + saveAllDirtyWorkspaceTextDocuments, + selectWorkspaceFolder, +} from "../workspace"; +import { workspaceFolderFormatting } from "./workspace-folder-formatting"; +import { fileExists } from "../utils"; + +/** + * Initialize a workspace folder for use with Air + * + * - Creates an empty `air.toml` if neither `air.toml` nor `.air.toml` already exist + * - Creates `extensions.json` recommending `Posit.air-vscode` + * - Updates `settings.json` to format-on-save R and Quarto, and sets default formatters + * - Updates `.Rbuildignore` to ignore `.vscode/` and `air.toml` if one existed + * + * Optionally, the user can request an immediate formatting of the workspace folder + * after initialization + */ +export async function workspaceFolderInitialization( + workspaceFolder: vscode.WorkspaceFolder, + binaryPath: string, +) { + const allSaved = await saveAllDirtyWorkspaceTextDocuments(workspaceFolder); + if (!allSaved) { + return; + } + + await createAirToml(workspaceFolder); + await createExtensionsJson(workspaceFolder); + await updateSettingsJson(workspaceFolder); + await updateRbuildignore(workspaceFolder); + + const message = `Successfully initialized the ${workspaceFolder.name} workspace folder.`; + const shouldFormatItem = `Click to format the ${workspaceFolder.name} workspace folder`; + + const item = await vscode.window.showInformationMessage( + message, + shouldFormatItem, + ); + + if (item === shouldFormatItem) { + await workspaceFolderFormatting(workspaceFolder, binaryPath); + } +} + +export function workspaceFolderInitializationCallback(ctx: Ctx): Cmd { + return async () => { + const workspaceFolder = await selectWorkspaceFolder(); + + if (!workspaceFolder) { + return; + } + + const binaryPath = ctx.lsp.getBinaryPath(); + + await workspaceFolderInitialization(workspaceFolder, binaryPath); + }; +} + +async function createAirToml( + workspaceFolder: vscode.WorkspaceFolder, +): Promise { + const airTomlUri = vscode.Uri.joinPath(workspaceFolder.uri, "air.toml"); + const dotAirTomlUri = vscode.Uri.joinPath(workspaceFolder.uri, ".air.toml"); + + if ((await fileExists(airTomlUri)) || (await fileExists(dotAirTomlUri))) { + return; + } + + // Create an empty `air.toml` + const content = new TextEncoder().encode(""); + await vscode.workspace.fs.writeFile(airTomlUri, content); +} + +/** + * Create `extensions.json` with Air recommended + * + * Unlike `settings.json`, there is no API for interacting with an + * `extensions.json` file. To keep things simple, we never try and update an + * existing one, we only create one if the user didn't have one already. We + * don't really want to get into the json parsing business just for this. + */ +async function createExtensionsJson( + workspaceFolder: vscode.WorkspaceFolder, +): Promise { + const vscodeUri = vscode.Uri.joinPath(workspaceFolder.uri, ".vscode"); + const extensionsUri = vscode.Uri.joinPath(vscodeUri, "extensions.json"); + + if (await fileExists(extensionsUri)) { + // Just bail if the user already has `extensions.json`, we don't + // try to update an existing one + return; + } + + if (!(await fileExists(vscodeUri))) { + await vscode.workspace.fs.createDirectory(vscodeUri); + } + + const contents = ` +{ + "recommendations": [ + "Posit.air-vscode" + ] +} + `; + const bytes = new TextEncoder().encode(contents); + + await vscode.workspace.fs.writeFile(extensionsUri, bytes); +} + +/** + * This seems to be the only way to update language specific settings for a + * particular workspace folder in a way that: + * + * - Doesn't destroy existing `[r]` or `[quarto]` settings that we aren't + * updating (like `config.update("[r]", value)` naively does) + * - Doesn't pull extra inherited global settings (like `config.get()` would do) + * + * The trick is to `inspect()` all of the `[r]` configuration specific to just + * the `workspaceFolder` and bulk update it, retaining all old settings but + * overriding the ones we care about updating. + * + * This does unfortunately drop comments in the `[r]` and `[quarto]` sections, + * but we can't do better. + * + * It would be great if you could do `update("[r].editor.formatOnSave")` to + * precisely target a single value of a single language, but you cannot. + */ +async function updateSettingsJson( + workspaceFolder: vscode.WorkspaceFolder, +): Promise { + const config = vscode.workspace.getConfiguration( + undefined, + workspaceFolder, + ); + + const configR = config.inspect("[r]")?.workspaceFolderValue || {}; + await config.update( + "[r]", + { + ...configR, + "editor.formatOnSave": true, + "editor.defaultFormatter": "Posit.air-vscode", + }, + vscode.ConfigurationTarget.WorkspaceFolder, + ); + + const configQuarto = config.inspect("[quarto]")?.workspaceFolderValue || {}; + await config.update( + "[quarto]", + { + ...configQuarto, + "editor.formatOnSave": true, + "editor.defaultFormatter": "quarto.quarto", + }, + vscode.ConfigurationTarget.WorkspaceFolder, + ); +} + +async function updateRbuildignore( + workspaceFolder: vscode.WorkspaceFolder, +): Promise { + const rbuildignoreUri = vscode.Uri.joinPath( + workspaceFolder.uri, + ".Rbuildignore", + ); + + if (!(await fileExists(rbuildignoreUri))) { + // Do nothing if the user doesn't have one + return; + } + + const buffer = await vscode.workspace.fs.readFile(rbuildignoreUri); + const content = new TextDecoder().decode(buffer); + + const newline = content.includes("\r\n") ? "\r\n" : "\n"; + const lines = content.split(newline); + + if (lines.at(-1) === "") { + // Drop final line if it's empty, i.e. we split on newlines and the + // file ended with a newline. This avoids writing a blank line between + // the existing content and our new lines. + lines.pop(); + } + + let anyMissing = false; + const patterns = ["^\\.vscode$", "^[.]?air[.]toml$"]; + + for (const pattern of patterns) { + const exists = lines.find((line) => line === pattern); + + if (!exists) { + anyMissing = true; + lines.push(pattern); + } + } + + if (anyMissing) { + const content = lines.join(newline) + newline; + const buffer = new TextEncoder().encode(content); + await vscode.workspace.fs.writeFile(rbuildignoreUri, buffer); + } +} diff --git a/editors/code/src/commands.ts b/editors/code/src/commands.ts index c085b810..207b1772 100644 --- a/editors/code/src/commands.ts +++ b/editors/code/src/commands.ts @@ -5,6 +5,7 @@ import AdmZip from "adm-zip"; import { Cmd, Ctx } from "./context"; import { viewFileUsingTextDocumentContentProvider } from "./request/viewFile"; import { VIEW_FILE } from "./request/viewFile"; +import { workspaceFolderInitializationCallback } from "./command/workspace-folder-initialization"; import { workspaceFolderFormattingCallback } from "./command/workspace-folder-formatting"; export function registerCommands(ctx: Ctx) { @@ -15,6 +16,13 @@ export function registerCommands(ctx: Ctx) { ), ); + ctx.extension.subscriptions.push( + vscode.commands.registerCommand( + "air.workspaceFolderInitialization", + workspaceFolderInitializationCallback(ctx), + ), + ); + ctx.extension.subscriptions.push( vscode.commands.registerCommand( "air.workspaceFolderFormatting", From 87b208b9ae862f941bc421664f432b0cc9eda6cf Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Thu, 24 Jul 2025 15:25:56 -0400 Subject: [PATCH 04/11] CHANGELOG bullet --- editors/code/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/editors/code/CHANGELOG.md b/editors/code/CHANGELOG.md index 786ec059..c23ff850 100644 --- a/editors/code/CHANGELOG.md +++ b/editors/code/CHANGELOG.md @@ -7,6 +7,8 @@ ## Development version +- New `Air: Initialize Workspace Folder` command to initialize a project for use with Air. This supercedes `usethis::use_air()` for VS Code and Positron users by providing an holistic setup experience from within the IDE (#323). + ## 0.14.0 From f18f8c0ad15566d64774a56480668cd12f2143c7 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Thu, 24 Jul 2025 15:28:58 -0400 Subject: [PATCH 05/11] Generalize some messages --- editors/code/src/workspace.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/editors/code/src/workspace.ts b/editors/code/src/workspace.ts index 4acd6fc2..b715a6ac 100644 --- a/editors/code/src/workspace.ts +++ b/editors/code/src/workspace.ts @@ -75,7 +75,7 @@ export async function selectWorkspaceFolder(): Promise< if (!workspaceFolders || workspaceFolders.length === 0) { vscode.window.showErrorMessage( - "You must be inside a workspace to format a workspace folder.", + "You must be inside a workspace to perform this action.", ); return undefined; } @@ -104,7 +104,7 @@ async function selectWorkspaceFolderFromQuickPick( workspaceFolderNames, { canPickMany: false, - title: "Which workspace folder should be formatted?", + title: "Select a workspace folder", }, ); From 5e0c308aa6051123946199c81c31061322b23772 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Thu, 24 Jul 2025 15:50:00 -0400 Subject: [PATCH 06/11] Typo --- editors/code/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editors/code/CHANGELOG.md b/editors/code/CHANGELOG.md index c23ff850..7be09c8b 100644 --- a/editors/code/CHANGELOG.md +++ b/editors/code/CHANGELOG.md @@ -7,7 +7,7 @@ ## Development version -- New `Air: Initialize Workspace Folder` command to initialize a project for use with Air. This supercedes `usethis::use_air()` for VS Code and Positron users by providing an holistic setup experience from within the IDE (#323). +- New `Air: Initialize Workspace Folder` command to initialize a project for use with Air. This supercedes `usethis::use_air()` for VS Code and Positron users by providing a holistic setup experience from within the IDE (#323). ## 0.14.0 From 06b2b241f756ddeb1b735699a7a5458337766557 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Thu, 24 Jul 2025 16:06:50 -0400 Subject: [PATCH 07/11] In `extensions.json`, trim whitespace but keep trailing newline --- editors/code/src/command/workspace-folder-initialization.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/editors/code/src/command/workspace-folder-initialization.ts b/editors/code/src/command/workspace-folder-initialization.ts index 6696d670..19067a1b 100644 --- a/editors/code/src/command/workspace-folder-initialization.ts +++ b/editors/code/src/command/workspace-folder-initialization.ts @@ -99,13 +99,16 @@ async function createExtensionsJson( await vscode.workspace.fs.createDirectory(vscodeUri); } - const contents = ` + let contents = ` { "recommendations": [ "Posit.air-vscode" ] } `; + contents = contents.trim(); + contents = contents + "\n"; + const bytes = new TextEncoder().encode(contents); await vscode.workspace.fs.writeFile(extensionsUri, bytes); From eaf822c35dbb6002d49bd79d90fddff27f18f103 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Wed, 30 Jul 2025 16:49:13 -0400 Subject: [PATCH 08/11] Mention `Air: Initialize Workspace Folder` in the docs --- docs/configuration.qmd | 2 +- docs/editor-vscode.qmd | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/configuration.qmd b/docs/configuration.qmd index 2aab77c6..079e3619 100644 --- a/docs/configuration.qmd +++ b/docs/configuration.qmd @@ -28,7 +28,7 @@ skip = [] ## Configuration recommendations For collaborative projects, we recommend creating an `air.toml` and placing it at your project root even if you plan to use the default Air settings. -The easiest way to do this is by running [`usethis::use_air()`](https://usethis.r-lib.org/dev/reference/use_air.html) (note that this currently requires the development version of usethis). +The easiest way to do this is by running the VS Code or Positron command `Air: Initialize Workspace Folder`, or by running [`usethis::use_air()`](https://usethis.r-lib.org/dev/reference/use_air.html) if you are using another IDE. The existence of an `air.toml` has a number of benefits: diff --git a/docs/editor-vscode.qmd b/docs/editor-vscode.qmd index 98faab7c..268dd5c8 100644 --- a/docs/editor-vscode.qmd +++ b/docs/editor-vscode.qmd @@ -10,9 +10,12 @@ Air provides first class support for both VS Code and Positron, which both suppo # Installation -Air is available [as an Extension](https://marketplace.visualstudio.com/items?itemName=Posit.air-vscode) for both VS Code and Positron. -The extension comes pre-bundled with an Air binary, so you don't need anything else to get going! -The Air extension is hosted in the VS Code Marketplace and on OpenVSX. +Air is available as a [VS Code Extension](https://marketplace.visualstudio.com/items?itemName=Posit.air-vscode) for VS Code. + +Air comes pre-bundled with Positron. +It is available as an [OpenVSX Extension](https://open-vsx.org/extension/posit/air-vscode) that is already installed for you. + +The extension for both VS Code and Positron comes with an Air binary, so you don't need anything else to get going! ## User vs Workspace settings @@ -27,7 +30,8 @@ We generally recommend modifying workspace level settings for two reasons: - User level settings are automatically applied for *all* projects that you open. While this sounds nice, if you open an older project (or a project you don't own) that doesn't use Air, then you'll have to remember to turn off your user level Air settings before committing to that project, otherwise you may create a large amount of format related diffs that the project may not want. -The easiest way to set up a workspace level `settings.json` with the recommended settings is by running [`usethis::use_air()`](https://usethis.r-lib.org/dev/reference/use_air.html) (note that this currently requires the development version of usethis). +The easiest way to set up a workspace level `settings.json` with the recommended settings is by running the command `Air: Initialize Workspace Folder`. +This is equivalent to running [`usethis::use_air()`](https://usethis.r-lib.org/dev/reference/use_air.html) from an R console. Alternatively, to open your `settings.json` file from the Command Palette (`Cmd + P` on Mac/Linux, `Ctrl + P` on Windows): @@ -49,8 +53,10 @@ Once you have the extension installed, turn on Format on Save for R documents by } ``` +Note that running the command `Air: Initialize Workspace Folder` will add this to your `settings.json` for you. + You should now be able to simply open an R document, save it, and have the entire document formatted by Air. -You can also explicitly call the command `Format Document` if you'd like to control this manually. +You can also explicitly call the command `Format Document` if you'd like to control the timing of formatting manually. @@ -67,7 +73,7 @@ Air ships with a special `Air: Format Workspace Folder` command to format all R This is particularly useful when transitioning an existing project over to Air, where you need to perform a project-wide format before utilizing the per-file format on save feature. Note that if you don't have an `air.toml` in your project, then this command will use Air's default settings rather than the IDE [settings synchronization mechanism](configuration.qmd#configuration-settings-synchronization). -We recommend using `usethis::use_air()` to set up an `air.toml` (among other things) before running this command. +We recommend using the command `Air: Initialize Workspace Folder` to set up an `air.toml` (among other things) before running this command. ## Quarto @@ -90,6 +96,8 @@ To format all R code cells on save, set this in your `settings.json`: } ``` +Note that running the command `Air: Initialize Workspace Folder` will add this to your `settings.json` for you. + ### Format cell From ab0d91bf69961eb80af8420dde9018521e9951a6 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Tue, 5 Aug 2025 07:57:50 -0400 Subject: [PATCH 09/11] Tweak comment --- editors/code/src/command/workspace-folder-initialization.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/editors/code/src/command/workspace-folder-initialization.ts b/editors/code/src/command/workspace-folder-initialization.ts index 19067a1b..46008b6a 100644 --- a/editors/code/src/command/workspace-folder-initialization.ts +++ b/editors/code/src/command/workspace-folder-initialization.ts @@ -172,7 +172,8 @@ async function updateRbuildignore( ); if (!(await fileExists(rbuildignoreUri))) { - // Do nothing if the user doesn't have one + // Do nothing if the user doesn't have one, i.e. an R project + // or the rare R package without an `.Rbuildignore` return; } From fb927edca0e5a70d6dd549a5588a061cef4311b8 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Tue, 5 Aug 2025 08:02:18 -0400 Subject: [PATCH 10/11] Accept suggestion --- editors/code/src/workspace.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editors/code/src/workspace.ts b/editors/code/src/workspace.ts index b715a6ac..1cacd929 100644 --- a/editors/code/src/workspace.ts +++ b/editors/code/src/workspace.ts @@ -64,7 +64,7 @@ function getWorkspaceFolders(): readonly vscode.WorkspaceFolder[] { /* * Select a workspace folder to act on * - * - If 0 workspaces are open, returns `undefined` with an error message. + * - If 0 workspaces are open, returns `undefined` after showing an error message. * - If 1 workspace is open, returns it. * - If >1 workspaces are open, shows the user a quick-pick menu to select their preference. */ From 36f9486d989a4f88aca4953f9c12f774f6e3a39d Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Tue, 5 Aug 2025 08:03:35 -0400 Subject: [PATCH 11/11] Note about untitled editors --- editors/code/src/workspace.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/editors/code/src/workspace.ts b/editors/code/src/workspace.ts index 1cacd929..8f8b3bf3 100644 --- a/editors/code/src/workspace.ts +++ b/editors/code/src/workspace.ts @@ -131,6 +131,7 @@ async function selectWorkspaceFolderFromQuickPick( * Save all open dirty editor tabs relevant to the workspace folder * * - Filters to only tabs living under the chosen workspace folder + * (note that this rules out untitled editors) * - Asks the user if they are okay with us saving the editor tabs */ export async function saveAllDirtyWorkspaceTextDocuments(