Skip to content

Commit

Permalink
Add support for extracting styles to tss (#1177)
Browse files Browse the repository at this point in the history
* build: update node related build settings to use newer syntax

* feat(providers/codeaction): allow extracting styles from an xml tag to a tss file

Implements a code action that allows extract styles from an xml node into a tss document. It can
be triggered using the usual refactor shortcut, menu item, or lightbulb and supports running on
an entire line or just the selected properties.

Some known todos are:

* Support nested properties (font stuff)
* Multiline properties
* Better handling of class merging
* Tests!

* feat: handle extracting object style properties

* fix: handle multi-line strings

* fix: allow percentages in values

* fix: handle not wrapping numbers, Alloy, Titanium APIs, wrap using correct quotes for file

* fix: handle “$.args” in attributes, show number-properties without quotes

* chore: add comma after new property

* fix: fix typo

* chore: remove last comma

---------

Co-authored-by: Hans Knöchel <h.knoechel@lambus.com>
  • Loading branch information
ewanharris and hansemannn authored Oct 29, 2023
1 parent ce06bd6 commit 74c2a3f
Show file tree
Hide file tree
Showing 7 changed files with 181 additions and 29 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
},
"engines": {
"vscode": "^1.73.0",
"node": ">=14.16.0"
"node": ">=16.14.2"
},
"activationEvents": [
"workspaceContains:**/tiapp.xml",
Expand Down
1 change: 1 addition & 0 deletions src/commands/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
17 changes: 14 additions & 3 deletions src/providers/code-action/viewCodeActionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<vscode.Command[]> {
public async provideCodeActions(document: vscode.TextDocument, range: vscode.Range|vscode.Selection): Promise<Array<vscode.Command|vscode.CodeAction>> {

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<vscode.CodeAction|vscode.Command> = [];

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;
}

Expand Down
4 changes: 2 additions & 2 deletions src/providers/definition/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ export interface DefinitionSuggestion {
insertText? (text: string): string|undefined;
}

async function getRelatedFiles(project: Project, fileType: string): Promise<string[]> {
export async function getRelatedFiles(project: Project, fileType: string, includeAppTss = true): Promise<string[]> {
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);
Expand Down
140 changes: 140 additions & 0 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string|number|Record<string, string|number>> = {};
const persistProperties: Record<string, string> = {};
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<string, string|number>)[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}`;
}
}

/**
Expand Down
42 changes: 21 additions & 21 deletions src/test/unit/suite/providers/code-action/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<vscode.Command[]> {
async function testCompletion(range: vscode.Range, uri = viewUri): Promise<Array<vscode.CodeAction|vscode.Command>> {
await vscode.window.showTextDocument(uri);
const text = await vscode.workspace.openTextDocument(uri);
return provider.provideCodeActions(text, range);
Expand All @@ -34,51 +34,51 @@ 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')
]);
});

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')
]);
});

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')
]);
});

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')
]);
Expand Down
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"target": "ES2021",
"outDir": "out",
"lib": [
"es2020"
"ES2021"
],
"moduleResolution": "node",
"sourceMap": true,
Expand Down

0 comments on commit 74c2a3f

Please sign in to comment.