diff --git a/package.json b/package.json index cf1a422..6cba229 100644 --- a/package.json +++ b/package.json @@ -19,36 +19,31 @@ ], "main": "./dist/extension.js", "activationEvents": [ - "onCommand:string-manipulation.titleize", - "onCommand:string-manipulation.titleizeApStyle", - "onCommand:string-manipulation.titleizeChicagoStyle", - "onCommand:string-manipulation.camelize", - "onCommand:string-manipulation.chop", - "onCommand:string-manipulation.clean", - "onCommand:string-manipulation.cleanDiacritics", - "onCommand:string-manipulation.classify", - "onCommand:string-manipulation.underscored", - "onCommand:string-manipulation.dasherize", - "onCommand:string-manipulation.snake", - "onCommand:string-manipulation.screamingSnake", - "onCommand:string-manipulation.humanize", - "onCommand:string-manipulation.slugify", - "onCommand:string-manipulation.reverse", - "onCommand:string-manipulation.swapCase", - "onCommand:string-manipulation.decapitalize", - "onCommand:string-manipulation.capitalize", - "onCommand:string-manipulation.sentence", - "onCommand:string-manipulation.prune", - "onCommand:string-manipulation.truncate", - "onCommand:string-manipulation.repeat", - "onCommand:string-manipulation.increment", - "onCommand:string-manipulation.decrement", - "onCommand:string-manipulation.duplicateAndIncrement", - "onCommand:string-manipulation.duplicateAndDecrement", - "onCommand:string-manipulation.sequence", - "onCommand:string-manipulation.randomCase" + "onCommand:string-manipulation.*", + "onView:stringManipulationSidebar" ], "contributes": { + "configuration": { + "type": "object", + "title": "String Manipulation Configuration", + "properties": { + "stringManipulation.labs": { + "type": "boolean", + "default": false, + "description": "Enable experimental String Manipulation Labs features." + } + } + }, + "views": { + "explorer": [ + { + "type": "webview", + "id": "stringManipulationSidebar", + "name": "String Manipulation", + "when": "config.stringManipulation.labs" + } + ] + }, "commands": [ { "title": "Titleize", diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..16b8597 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,205 @@ +import * as vscode from "vscode"; +import * as underscore from "underscore.string"; +const apStyleTitleCase = require("ap-style-title-case"); +const chicagoStyleTitleCase = require("chicago-capitalize"); +const slugify = require("@sindresorhus/slugify"); + +interface MultiSelectData { + offset?: number; +} + +const defaultFunction = (commandName: string, option?: any) => (str: string) => + (underscore as any)[commandName](str, option); + +const sequence = (str: string, multiselectData: MultiSelectData = {}) => { + return str.replace(/-?\d+/g, (n) => { + const isFirst = typeof multiselectData.offset !== "number"; + multiselectData.offset = isFirst + ? Number(n) + : (multiselectData.offset || 0) + 1; + return String(multiselectData.offset); + }); +}; + +const increment = (str: string) => + str.replace(/-?\d+/g, (n) => String(Number(n) + 1)); + +const decrement = (str: string) => + str.replace(/-?\d+/g, (n) => String(Number(n) - 1)); + +const randomCase = (input: string): string => { + let result = ""; + for (const char of input) { + if (Math.random() < 0.5) { + result += char.toLowerCase(); + } else { + result += char.toUpperCase(); + } + } + return result; +}; + +export type StringFunction = ( + str: string, + multiselectData?: MultiSelectData +) => string; +export type CommandFunction = + | StringFunction + | ((...args: any[]) => StringFunction); + +const commandNameFunctionMap: { [key: string]: CommandFunction } = { + titleize: defaultFunction("titleize"), + chop: (n: number) => defaultFunction("chop", n), + classify: defaultFunction("classify"), + clean: defaultFunction("clean"), + cleanDiacritics: defaultFunction("cleanDiacritics"), + underscored: defaultFunction("underscored"), + dasherize: defaultFunction("dasherize"), + humanize: defaultFunction("humanize"), + reverse: defaultFunction("reverse"), + decapitalize: defaultFunction("decapitalize"), + capitalize: defaultFunction("capitalize"), + sentence: defaultFunction("capitalize", true), + camelize: (str: string) => + underscore.camelize(/[a-z]/.test(str) ? str : str.toLowerCase()), + slugify: slugify, + swapCase: defaultFunction("swapCase"), + snake: (str: string) => + underscore + .underscored(str) + .replace(/([A-Z])[^A-Z]/g, " $1") + .replace(/[^a-z]+/gi, " ") + .trim() + .replace(/\s/gi, "_"), + screamingSnake: (str: string) => + underscore + .underscored(str) + .replace(/([A-Z])[^A-Z]/g, " $1") + .replace(/[^a-z]+/gi, " ") + .trim() + .replace(/\s/gi, "_") + .toUpperCase(), + titleizeApStyle: apStyleTitleCase, + titleizeChicagoStyle: chicagoStyleTitleCase, + truncate: (n: number) => defaultFunction("truncate", n), + prune: (n: number) => (str: string) => str.slice(0, n - 3).trim() + "...", + repeat: (n: number) => defaultFunction("repeat", n), + increment, + decrement, + duplicateAndIncrement: (str: string) => str + increment(str), + duplicateAndDecrement: (str: string) => str + decrement(str), + sequence, + utf8ToChar: (str: string) => + str + .match(/\\u[\dA-Fa-f]{4}/g) + ?.map((x) => x.slice(2)) + .map((x) => String.fromCharCode(parseInt(x, 16))) + .join("") || "", + charToUtf8: (str: string) => + str + .split("") + .map((x) => `\\u${x.charCodeAt(0).toString(16).padStart(4, "0")}`) + .join(""), + randomCase, +}; + +const numberFunctionNames = [ + "increment", + "decrement", + "sequence", + "duplicateAndIncrement", + "duplicateAndDecrement", +]; + +export const functionNamesWithArgument = [ + "chop", + "truncate", + "prune", + "repeat", +]; + +export const stringFunction = async ( + commandName: string, + context: vscode.ExtensionContext, + shouldApply = true +): Promise<{ replacedSelections: string[] } | undefined> => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const selectionMap: { + [key: number]: { selection: vscode.Selection; replaced: string }; + } = {}; + + let multiselectData: MultiSelectData = {}; + + let stringFunc: (str: string) => string; + + let replacedSelections = []; + + if (functionNamesWithArgument.includes(commandName)) { + const valueStr = await vscode.window.showInputBox(); + if (valueStr === undefined) { + return; + } + const value = Number(valueStr); + if (isNaN(value)) { + vscode.window.showErrorMessage("Invalid number"); + return; + } + stringFunc = (commandNameFunctionMap[commandName] as Function)(value); + } else if (numberFunctionNames.includes(commandName)) { + stringFunc = (str: string) => + (commandNameFunctionMap[commandName] as Function)(str, multiselectData); + } else { + stringFunc = commandNameFunctionMap[commandName] as StringFunction; + } + + for (const [index, selection] of editor.selections.entries()) { + const text = editor.document.getText(selection); + const textParts = text.split("\n"); + const replaced = textParts.map((part) => stringFunc(part)).join("\n"); + replacedSelections.push(replaced); + selectionMap[index] = { selection, replaced }; + } + + if (shouldApply) { + await editor.edit((builder) => { + Object.values(selectionMap).forEach(({ selection, replaced }) => { + builder.replace(selection, replaced); + }); + }); + + context.globalState.update("lastAction", commandName); + } + + return await Promise.resolve({ replacedSelections }); +}; + +export function activate(context: vscode.ExtensionContext) { + context.globalState.setKeysForSync(["lastAction"]); + + context.subscriptions.push( + vscode.commands.registerCommand( + "string-manipulation.repeatLastAction", + () => { + const lastAction = context.globalState.get("lastAction"); + if (lastAction) { + return stringFunction(lastAction, context); + } + } + ) + ); + + Object.keys(commandNameFunctionMap).forEach((commandName) => { + context.subscriptions.push( + vscode.commands.registerCommand( + `string-manipulation.${commandName}`, + () => stringFunction(commandName, context) + ) + ); + }); +} + +export { commandNameFunctionMap }; diff --git a/src/extension.ts b/src/extension.ts index 8c24b60..d35001d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,192 +1,22 @@ import * as vscode from "vscode"; -import * as underscore from "underscore.string"; -const apStyleTitleCase = require("ap-style-title-case"); -const chicagoStyleTitleCase = require("chicago-capitalize"); -const slugify = require("@sindresorhus/slugify"); - -interface MultiSelectData { - offset?: number; -} - -const defaultFunction = (commandName: string, option?: any) => (str: string) => - (underscore as any)[commandName](str, option); - -const sequence = (str: string, multiselectData: MultiSelectData = {}) => { - return str.replace(/-?\d+/g, (n) => { - const isFirst = typeof multiselectData.offset !== "number"; - multiselectData.offset = isFirst - ? Number(n) - : (multiselectData.offset || 0) + 1; - return String(multiselectData.offset); - }); -}; - -const increment = (str: string) => - str.replace(/-?\d+/g, (n) => String(Number(n) + 1)); - -const decrement = (str: string) => - str.replace(/-?\d+/g, (n) => String(Number(n) - 1)); - -const randomCase = (input: string): string => { - let result = ""; - for (const char of input) { - if (Math.random() < 0.5) { - result += char.toLowerCase(); - } else { - result += char.toUpperCase(); - } - } - return result; -}; - -export type StringFunction = ( - str: string, - multiselectData?: MultiSelectData -) => string; -export type CommandFunction = - | StringFunction - | ((...args: any[]) => StringFunction); - -const commandNameFunctionMap: { [key: string]: CommandFunction } = { - titleize: defaultFunction("titleize"), - chop: (n: number) => defaultFunction("chop", n), - classify: defaultFunction("classify"), - clean: defaultFunction("clean"), - cleanDiacritics: defaultFunction("cleanDiacritics"), - underscored: defaultFunction("underscored"), - dasherize: defaultFunction("dasherize"), - humanize: defaultFunction("humanize"), - reverse: defaultFunction("reverse"), - decapitalize: defaultFunction("decapitalize"), - capitalize: defaultFunction("capitalize"), - sentence: defaultFunction("capitalize", true), - camelize: (str: string) => - underscore.camelize(/[a-z]/.test(str) ? str : str.toLowerCase()), - slugify: slugify, - swapCase: defaultFunction("swapCase"), - snake: (str: string) => - underscore - .underscored(str) - .replace(/([A-Z])[^A-Z]/g, " $1") - .replace(/[^a-z]+/gi, " ") - .trim() - .replace(/\s/gi, "_"), - screamingSnake: (str: string) => - underscore - .underscored(str) - .replace(/([A-Z])[^A-Z]/g, " $1") - .replace(/[^a-z]+/gi, " ") - .trim() - .replace(/\s/gi, "_") - .toUpperCase(), - titleizeApStyle: apStyleTitleCase, - titleizeChicagoStyle: chicagoStyleTitleCase, - truncate: (n: number) => defaultFunction("truncate", n), - prune: (n: number) => (str: string) => str.slice(0, n - 3).trim() + "...", - repeat: (n: number) => defaultFunction("repeat", n), - increment, - decrement, - duplicateAndIncrement: (str: string) => str + increment(str), - duplicateAndDecrement: (str: string) => str + decrement(str), - sequence, - utf8ToChar: (str: string) => - str - .match(/\\u[\dA-Fa-f]{4}/g) - ?.map((x) => x.slice(2)) - .map((x) => String.fromCharCode(parseInt(x, 16))) - .join("") || "", - charToUtf8: (str: string) => - str - .split("") - .map((x) => `\\u${x.charCodeAt(0).toString(16).padStart(4, "0")}`) - .join(""), - randomCase, -}; - -const numberFunctionNames = [ - "increment", - "decrement", - "sequence", - "duplicateAndIncrement", - "duplicateAndDecrement", -]; - -const functionNamesWithArgument = ["chop", "truncate", "prune", "repeat"]; - -const stringFunction = async ( - commandName: string, - context: vscode.ExtensionContext -) => { - const editor = vscode.window.activeTextEditor; - if (!editor) { - return; - } - - const selectionMap: { - [key: number]: { selection: vscode.Selection; replaced: string }; - } = {}; - - let multiselectData: MultiSelectData = {}; - - let stringFunc: (str: string) => string; - - if (functionNamesWithArgument.includes(commandName)) { - const valueStr = await vscode.window.showInputBox(); - if (valueStr === undefined) { - return; - } - const value = Number(valueStr); - if (isNaN(value)) { - vscode.window.showErrorMessage("Invalid number"); - return; - } - stringFunc = (commandNameFunctionMap[commandName] as Function)(value); - } else if (numberFunctionNames.includes(commandName)) { - stringFunc = (str: string) => - (commandNameFunctionMap[commandName] as Function)(str, multiselectData); - } else { - stringFunc = commandNameFunctionMap[commandName] as StringFunction; - } - - for (const [index, selection] of editor.selections.entries()) { - const text = editor.document.getText(selection); - const textParts = text.split("\n"); - const replaced = textParts.map((part) => stringFunc(part)).join("\n"); - selectionMap[index] = { selection, replaced }; - } - - await editor.edit((builder) => { - Object.values(selectionMap).forEach(({ selection, replaced }) => { - builder.replace(selection, replaced); - }); - }); - - context.globalState.update("lastAction", commandName); -}; +import { StringManipulationSidebar } from "./sidebar"; +import { activate as stringManipulationActivate } from "./commands"; export function activate(context: vscode.ExtensionContext) { - context.globalState.setKeysForSync(["lastAction"]); + stringManipulationActivate(context); + + const sidebarProvider = new StringManipulationSidebar(context); context.subscriptions.push( - vscode.commands.registerCommand( - "string-manipulation.repeatLastAction", - () => { - const lastAction = context.globalState.get("lastAction"); - if (lastAction) { - return stringFunction(lastAction, context); - } - } + vscode.window.registerWebviewViewProvider( + StringManipulationSidebar.viewType, + sidebarProvider, + { webviewOptions: { retainContextWhenHidden: true } } ) ); - Object.keys(commandNameFunctionMap).forEach((commandName) => { - context.subscriptions.push( - vscode.commands.registerCommand( - `string-manipulation.${commandName}`, - () => stringFunction(commandName, context) - ) - ); - }); + // Update the sidebar initially + sidebarProvider.updateWebview(); } -export { commandNameFunctionMap }; +export function deactivate() {} diff --git a/src/sidebar.ts b/src/sidebar.ts new file mode 100644 index 0000000..0e6faea --- /dev/null +++ b/src/sidebar.ts @@ -0,0 +1,221 @@ +// src/StringManipulationSidebar.ts + +import * as vscode from "vscode"; +import { + commandNameFunctionMap, + stringFunction, + functionNamesWithArgument, +} from "./commands"; + +export class StringManipulationSidebar implements vscode.WebviewViewProvider { + public static readonly viewType = "stringManipulationSidebar"; + + private _view?: vscode.WebviewView; + + constructor(private readonly context: vscode.ExtensionContext) { + vscode.window.onDidChangeActiveTextEditor( + () => { + this.updateWebview(); + }, + null, + this.context.subscriptions + ); + vscode.workspace.onDidChangeTextDocument( + () => { + this.updateWebview(); + }, + null, + this.context.subscriptions + ); + } + + resolveWebviewView( + webviewView: vscode.WebviewView, + context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ) { + this._view = webviewView; + + webviewView.webview.options = { + // Allow scripts in the webview + enableScripts: true, + + // Restrict the webview to only loading content from `out` directory + localResourceRoots: [this.context.extensionUri], + }; + + webviewView.webview.html = this.getHtmlForWebview(webviewView.webview); + + // Handle messages from the webview + webviewView.webview.onDidReceiveMessage(async (message) => { + switch (message.type) { + case "applyCommand": + const commandName = message.command; + await this.applyCommand(commandName); + break; + } + }); + + // Update the webview content when the selection changes + vscode.window.onDidChangeTextEditorSelection( + () => { + this.updateWebview(); + }, + null, + this.context.subscriptions + ); + } + + private getHtmlForWebview(webview: vscode.Webview): string { + // You can use a more sophisticated HTML setup or a front-end framework + return ` + + + + String Manipulation + + + +

Available Transformations

+
+ + + +`; + } + + private async applyCommand(commandName: string) { + const stringFunc = commandNameFunctionMap[commandName]; + if (!stringFunc) { + vscode.window.showErrorMessage(`Command "${commandName}" not found.`); + return; + } + + stringFunction(commandName, this.context, /* shouldApplyChanges */ true); + + vscode.window.showInformationMessage( + `Applied "${commandName}" to selected text.` + ); + } + + public async updateWebview() { + if (!this._view) { + return; + } + + const editor = vscode.window.activeTextEditor; + if (!editor) { + this._view.webview.postMessage({ type: "updateCommands", commands: [] }); + return; + } + + const selections = editor.selections; + if (selections.length === 0) { + this._view.webview.postMessage({ type: "updateCommands", commands: [] }); + return; + } + + // For simplicity, we'll use the first selection + const selectedText = editor.document.getText(selections[0]); + + if (!selectedText) { + this._view.webview.postMessage({ type: "updateCommands", commands: [] }); + return; + } + + // Apply all commands to the selected text + const commands = await Promise.all( + Object.keys(commandNameFunctionMap) + .filter( + (commandName) => !functionNamesWithArgument.includes(commandName) + ) + .map(async (cmdName) => { + const { replacedSelections } = (await stringFunction( + cmdName, + this.context, + /* shouldApplyChanges */ false + )) as { replacedSelections: string[] }; + + let output = replacedSelections.join("
...
"); + + return { + name: cmdName, + output, + originalText: selectedText, + }; + }) + ); + + // Send the commands to the webview + this._view.webview.postMessage({ type: "updateCommands", commands }); + } +} diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index d00fabc..f12f114 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -3,7 +3,7 @@ import * as assert from "assert"; // You can import and use all API from the 'vscode' module // as well as import your extension to test it import * as vscode from "vscode"; -import * as myExtension from "../extension"; +import { commandNameFunctionMap, CommandFunction } from "../commands"; type StringTransformationTest = [ funcName: string, @@ -140,65 +140,76 @@ suite("Extension Test Suite", () => { test(`${funcName} returns ${expectedString} when called with ${args}`, () => { const func = ( functionArg - ? myExtension.commandNameFunctionMap[funcName](functionArg as any) - : myExtension.commandNameFunctionMap[funcName] - ) as myExtension.CommandFunction; + ? commandNameFunctionMap[funcName](functionArg as any) + : commandNameFunctionMap[funcName] + ) as CommandFunction; assert.equal(func(originalString, multiselectData), expectedString); }); }); suite("randomCase", () => { - const input = "Hello, World!"; + const input = "Hello, World!"; - test("returns a string of the same length", () => { - const output = myExtension.commandNameFunctionMap["randomCase"]( - input - ) as string; - assert.equal(output.length, input.length); - }); + test("returns a string of the same length", () => { + const output = commandNameFunctionMap["randomCase"](input) as string; + assert.equal(output.length, input.length); + }); - test("contains the same characters ignoring case", () => { - const output = myExtension.commandNameFunctionMap["randomCase"]( - input - ) as string; - assert.equal(output.toLowerCase(), input.toLowerCase()); - }); + test("contains the same characters ignoring case", () => { + const output = commandNameFunctionMap["randomCase"](input) as string; + assert.equal(output.toLowerCase(), input.toLowerCase()); + }); - test("changes the case of at least one character (statistically)", () => { - let changed = false; - for (let i = 0; i < 10; i++) { - const output = myExtension.commandNameFunctionMap["randomCase"]( - input - ) as string; - if (output !== input && output.toLowerCase() === input.toLowerCase()) { - changed = true; - break; + test("changes the case of at least one character (statistically)", () => { + let changed = false; + for (let i = 0; i < 10; i++) { + const output = commandNameFunctionMap["randomCase"](input) as string; + if ( + output !== input && + output.toLowerCase() === input.toLowerCase() + ) { + changed = true; + break; + } } - } - assert.equal(changed, true); - }); + assert.equal(changed, true); + }); - test("handles empty strings", () => { - const output = myExtension.commandNameFunctionMap.randomCase(""); - assert.equal(output, ""); - }); + test("handles empty strings", () => { + const output = commandNameFunctionMap.randomCase(""); + assert.equal(output, ""); + }); - test("preserves non-alphabetic characters", () => { - const specialChars = "12345!@#$%"; - const output = - myExtension.commandNameFunctionMap.randomCase(specialChars); - assert.equal(output, specialChars); + test("preserves non-alphabetic characters", () => { + const specialChars = "12345!@#$%"; + const output = commandNameFunctionMap.randomCase(specialChars); + assert.equal(output, specialChars); + }); + + test("handles strings with mixed content", () => { + const mixedInput = "Test123!"; + const output = commandNameFunctionMap.randomCase(mixedInput) as string; + assert.equal(output.length, mixedInput.length); + assert.notEqual(output.replace(/[^a-zA-Z]/g, ""), ""); + }); }); + }); - test("handles strings with mixed content", () => { - const mixedInput = "Test123!"; - const output = myExtension.commandNameFunctionMap.randomCase( - mixedInput - ) as string; - assert.equal(output.length, mixedInput.length); - assert.notEqual(output.replace(/[^a-zA-Z]/g, ""), ""); + suite("activation events", () => { + const extension = vscode.extensions.getExtension( + "marclipovsky.string-manipulation" + )!; + + test("is not active by default", () => { + const extension = vscode.extensions.getExtension( + "marclipovsky.string-manipulation" + )!; + assert.equal(false, extension.isActive); }); - }); + test("invoked when running one of the commands", async () => { + await vscode.commands.executeCommand("string-manipulation.titleize"); + assert.equal(true, extension.isActive); + }); }); });