diff --git a/package.json b/package.json index 8e77b847a..d55350cf2 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ }, "engines": { "vscode": "^1.73.0", - "node": ">=14.16.0" + "node": ">=16.14.2" }, "activationEvents": [ "workspaceContains:**/tiapp.xml", diff --git a/src/commands/common.ts b/src/commands/common.ts index 115bd75f8..2a3b057a5 100644 --- a/src/commands/common.ts +++ b/src/commands/common.ts @@ -16,6 +16,7 @@ export enum Commands { Debug = 'titanium.build.debug', DisableLiveView = 'titanium.build.setLiveViewDisabled', EnableLiveView = 'titanium.build.setLiveViewEnabled', + ExtractStyle = 'titanium.extractToTss', FixEnvironmentIssues = 'titanium.environment.fixIssues', GenerateAlloyController = 'titanium.alloy.generate.controller', GenerateAlloyMigration = 'titanium.alloy.generate.migration', diff --git a/src/providers/code-action/viewCodeActionProvider.ts b/src/providers/code-action/viewCodeActionProvider.ts index 87c66f62d..ddb08508b 100644 --- a/src/providers/code-action/viewCodeActionProvider.ts +++ b/src/providers/code-action/viewCodeActionProvider.ts @@ -6,17 +6,28 @@ import { viewSuggestions as suggestions } from '../definition/common'; import { Commands } from '../../commands'; export class ViewCodeActionProvider extends BaseProvider implements vscode.CodeActionProvider { - public async provideCodeActions(document: vscode.TextDocument, range: vscode.Range): Promise { + public async provideCodeActions(document: vscode.TextDocument, range: vscode.Range|vscode.Selection): Promise> { + const project = await this.getProject(document); if (!project) { return []; } + const linePrefix = document.getText(new vscode.Range(range.end.line, 0, range.end.line, range.end.character)); const wordRange = document.getWordRangeAtPosition(range.end); const word = wordRange ? document.getText(wordRange) : null; - const codeActions: vscode.Command[] = []; + const codeActions: Array = []; + + const extract = new vscode.CodeAction('Extract style', vscode.CodeActionKind.RefactorExtract); + extract.command = { + command: Commands.ExtractStyle, + title: 'Extract to style', + arguments: [ document, range, project ] + }; + + codeActions.push(extract); - if (!word || word.length === 0) { + if (!word) { return codeActions; } diff --git a/src/providers/definition/common.ts b/src/providers/definition/common.ts index aaa1e5730..e4a0b86de 100644 --- a/src/providers/definition/common.ts +++ b/src/providers/definition/common.ts @@ -13,9 +13,9 @@ export interface DefinitionSuggestion { insertText? (text: string): string|undefined; } -async function getRelatedFiles(project: Project, fileType: string): Promise { +export async function getRelatedFiles(project: Project, fileType: string, includeAppTss = true): Promise { const relatedFiles: string[] = []; - if (fileType === 'tss') { + if (fileType === 'tss' && includeAppTss) { relatedFiles.push(path.join(project.filePath, 'app', 'styles', 'app.tss')); } const relatedFile = await related.getTargetPath(project, fileType); diff --git a/src/providers/index.ts b/src/providers/index.ts index 6ef1bd383..9f4242b07 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -19,6 +19,8 @@ import { ViewDefinitionProvider } from './definition/viewDefinitionProvider'; import { ViewHoverProvider } from './hover/viewHoverProvider'; import { ExtensionContainer } from '../container'; import { TiTerminalLinkProvider } from './terminalLinkProvider'; +import { inputBox, quickPick } from '../quickpicks'; +import { getRelatedFiles } from './definition/common'; const viewFilePattern = '**/app/{views,widgets}/**/*.xml'; const styleFilePattern = '**/*.tss'; @@ -89,6 +91,144 @@ export function registerProviders(context: vscode.ExtensionContext): void { vscode.workspace.applyEdit(edit); } }); + + registerCommand(Commands.ExtractStyle, async (document: vscode.TextDocument, selection: vscode.Range|vscode.Selection, project: Project) => { + let contents; + let isSelection = true; + // First try and extract the selected text if its a selection + if ((selection as vscode.Selection).anchor) { + contents = document.getText(new vscode.Range(selection.start.line, selection.start.character, selection.end.line, selection.end.character)); + } + + if (!contents) { + isSelection = false; + contents = document.lineAt(selection.start.line).text; + } + + const lineMatches = contents.match(/(\s+)?(?:<(\w+))?((?:\s*[\w.]+="(?:\$\.args\.[\w./%]+|[\w./%]+)")+)\s*(?:(\/>|>(?:.*<\/\w+>)?)?)/); + if (!lineMatches) { + return; + } + const [ , spaces, tag, propertiesString, endingTag ] = lineMatches; + + const properties: Record> = {}; + const persistProperties: Record = {}; + for (const property of propertiesString.replace(/\s+/g, ' ').trim().split(' ')) { + const [ name, value ] = property.split('='); + if (/^(?:on|id|class|platform|ns)/.test(name)) { + persistProperties[name] = value; + continue; + } + + let cleanValue; + if (!isNaN(Number(value))) { + cleanValue = Number(value); + } else { + cleanValue = value.replaceAll('"', ''); + } + + if (name.includes('.')) { + const [ parent, child ] = name.split('.'); + if (!properties[parent]) { + properties[parent] = {}; + } + + (properties[parent] as Record)[child] = cleanValue; + } else { + properties[name] = cleanValue; + } + + } + let styleName; + const extractChoices = [ 'class', 'id' ]; + if (tag) { + extractChoices.push('tag'); + } + + const tssFilePath = (await getRelatedFiles(project, 'tss', false))[0]; + const tssDocument = await vscode.workspace.openTextDocument(tssFilePath); + const extractType = await quickPick(extractChoices, { placeHolder: 'Choose style' }); + if (extractType === 'class' || extractType === 'id') { + const name = await inputBox({ prompt: `Enter the name for your ${extractType}`, + validateInput: (value) => { + const prefix = extractType === 'class' ? '.' : '#'; + if (tssDocument.getText().includes(`"${prefix}${value}"`)) { + return `The ${extractType} value already exists in the tss`; + } + } + }); + const prefix = extractType === 'class' ? '.' : '#'; + styleName = `${prefix}${name}`; + + // handle merging classes if one already exists + if (extractType === 'class' && persistProperties.class) { + persistProperties.class = `"${persistProperties.class.replaceAll('"', '')} ${name}"`; + } else { + persistProperties[extractType] = `"${name}"`; + } + } else { + styleName = tag; + } + + let quoteType = '"'; + if (tssDocument.getText().includes('\'')) { + quoteType = '\''; + } + + let styleString = `\n${quoteType}${styleName}${quoteType}: {`; + for (const [ name, value ] of Object.entries(properties)) { + if (typeof value === 'string') { + styleString = `${styleString}\n\t${name}: ${wrapValue(value, quoteType)},`; + } else { + let subObject = `\n\t${name}: {`; + for (const [ subName, subValue ] of Object.entries(value)) { + subObject = `${subObject}\n\t\t${subName}: ${wrapValue(subValue, quoteType)},`; + } + subObject = `${subObject}\n\t}`; + styleString = `${styleString}${subObject}`; + } + } + + styleString = `${styleString.slice(0, -1)}\n}`; + + const position = new vscode.Position(tssDocument.lineCount, 0); + if (tssDocument.lineAt(position.line - 1).text.trim().length) { + styleString = `\n${styleString}`; + } + const edit = new vscode.WorkspaceEdit(); + edit.insert(vscode.Uri.file(tssFilePath), position, styleString); + + const newPropertiesString = Object.entries(persistProperties).map(([ name, value ]) => `${name}=${value}`).join(' '); + let newLine = ''; + if (tag) { + newLine += `<${tag} `; + } + + newLine += newPropertiesString; + + if (endingTag) { + newLine += ` ${endingTag}`; + } + + let replaceRange; + if (isSelection) { + replaceRange = new vscode.Range(selection.start.line, selection.start.character, selection.start.line, selection.end.character); + } else { + newLine = `${spaces}${newLine}`; + replaceRange = new vscode.Range(selection.start.line, 0, selection.start.line, contents.length); + } + + edit.replace(vscode.Uri.file(document.uri.fsPath), replaceRange, newLine); + vscode.workspace.applyEdit(edit, { isRefactoring: true }); + }); +} + +function wrapValue(value: string|number, quote: string) { + if (typeof value !== 'string' || (value.startsWith('Alloy.') || value.startsWith('Ti.') || value.startsWith('Titanium.') || value.startsWith('$.args.') || !isNaN(Number(value)))) { + return value; + } else { + return `${quote}${value}${quote}`; + } } /** diff --git a/src/test/unit/suite/providers/code-action/view.test.ts b/src/test/unit/suite/providers/code-action/view.test.ts index 29b5db8c1..3a60e444b 100644 --- a/src/test/unit/suite/providers/code-action/view.test.ts +++ b/src/test/unit/suite/providers/code-action/view.test.ts @@ -15,7 +15,7 @@ let sandbox: sinon.SinonSandbox; describe('View code actions', () => { const provider = new ViewCodeActionProvider(); - async function testCompletion(range: vscode.Range, uri = viewUri): Promise { + async function testCompletion(range: vscode.Range, uri = viewUri): Promise> { await vscode.window.showTextDocument(uri); const text = await vscode.workspace.openTextDocument(uri); return provider.provideCodeActions(text, range); @@ -34,12 +34,12 @@ describe('View code actions', () => { it('should provide code actions for ids', async () => { const range = new vscode.Range(new vscode.Position(17, 19), new vscode.Position(17, 20)); - const actions = await testCompletion(range); + const actions = await testCompletion(range) as vscode.Command[]; - expect(actions.length).to.equal(1); - expect(actions[0].command).to.equal('titanium.insertCodeAction'); - expect(actions[0].title).to.equal('Generate style (sample)'); - expect(actions[0].arguments).to.deep.equal([ + expect(actions.length).to.equal(2); + expect(actions[1].command).to.equal('titanium.insertCodeAction'); + expect(actions[1].title).to.equal('Generate style (sample)'); + expect(actions[1].arguments).to.deep.equal([ '\n\'#noexistid\': {\n}\n', path.join(getCommonAlloyProjectDirectory(), 'app', 'styles', 'sample.tss') ]); @@ -47,12 +47,12 @@ describe('View code actions', () => { it('should provide code actions for classes', async () => { const range = new vscode.Range(new vscode.Position(17, 37), new vscode.Position(17, 39)); - const actions = await testCompletion(range); + const actions = await testCompletion(range) as vscode.Command[]; - expect(actions.length).to.equal(1); - expect(actions[0].command).to.equal('titanium.insertCodeAction'); - expect(actions[0].title).to.equal('Generate style (sample)'); - expect(actions[0].arguments).to.deep.equal([ + expect(actions.length).to.equal(2); + expect(actions[1].command).to.equal('titanium.insertCodeAction'); + expect(actions[1].title).to.equal('Generate style (sample)'); + expect(actions[1].arguments).to.deep.equal([ '\n\'.noexistclass\': {\n}\n', path.join(getCommonAlloyProjectDirectory(), 'app', 'styles', 'sample.tss') ]); @@ -60,12 +60,12 @@ describe('View code actions', () => { it('should provide code actions for a tag', async () => { const range = new vscode.Range(new vscode.Position(17, 4), new vscode.Position(17, 6)); - const actions = await testCompletion(range); + const actions = await testCompletion(range) as vscode.Command[]; - expect(actions.length).to.equal(1); - expect(actions[0].command).to.equal('titanium.insertCodeAction'); - expect(actions[0].title).to.equal('Generate style (sample)'); - expect(actions[0].arguments).to.deep.equal([ + expect(actions.length).to.equal(2); + expect(actions[1].command).to.equal('titanium.insertCodeAction'); + expect(actions[1].title).to.equal('Generate style (sample)'); + expect(actions[1].arguments).to.deep.equal([ '\n\'View\': {\n}\n', path.join(getCommonAlloyProjectDirectory(), 'app', 'styles', 'sample.tss') ]); @@ -73,12 +73,12 @@ describe('View code actions', () => { it('should provide code actions for event handlers', async () => { const range = new vscode.Range(new vscode.Position(17, 55), new vscode.Position(17, 60)); - const actions = await testCompletion(range); + const actions = await testCompletion(range) as vscode.Command[]; - expect(actions.length).to.equal(1); - expect(actions[0].command).to.equal('titanium.insertCodeAction'); - expect(actions[0].title).to.equal('Generate function (sample)'); - expect(actions[0].arguments).to.deep.equal([ + expect(actions.length).to.equal(2); + expect(actions[1].command).to.equal('titanium.insertCodeAction'); + expect(actions[1].title).to.equal('Generate function (sample)'); + expect(actions[1].arguments).to.deep.equal([ '\nfunction noExistFunc(e){\n}\n', path.join(getCommonAlloyProjectDirectory(), 'app', 'controllers', 'sample.js') ]); diff --git a/tsconfig.json b/tsconfig.json index f94f3ca9f..cce57fbad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { "module": "commonjs", - "target": "es6", + "target": "ES2021", "outDir": "out", "lib": [ - "es2020" + "ES2021" ], "moduleResolution": "node", "sourceMap": true,