From 1cc8e62da32895c892624a9f631cd7d16973aa81 Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Thu, 31 Aug 2023 15:53:20 +0100 Subject: [PATCH] Add "Sort Selection" command (#851) * Add `sortSelection` command * wip * wip * wip * wip * wip * wip * Add test * Update command name and description * Don't show sort command if file is excluded --- .../tailwindcss-language-server/src/server.ts | 100 +++++++++++- .../tests/commands/commands.test.js | 14 ++ packages/vscode-tailwindcss/README.md | 10 ++ packages/vscode-tailwindcss/package.json | 5 + packages/vscode-tailwindcss/src/extension.ts | 143 ++++++++++++++++++ 5 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 packages/tailwindcss-language-server/tests/commands/commands.test.js diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts index 721a37b..a038aea 100644 --- a/packages/tailwindcss-language-server/src/server.ts +++ b/packages/tailwindcss-language-server/src/server.ts @@ -74,7 +74,7 @@ import { getModuleDependencies } from './util/getModuleDependencies' import assert from 'assert' // import postcssLoadConfig from 'postcss-load-config' import * as parcel from './watcher/index.js' -import { generateRules } from 'tailwindcss-language-service/src/util/jit' +import { bigSign } from 'tailwindcss-language-service/src/util/jit' import { getColor } from 'tailwindcss-language-service/src/util/color' import * as culori from 'culori' import namedColors from 'color-name' @@ -195,6 +195,7 @@ interface ProjectService { onColorPresentation(params: ColorPresentationParams): Promise onCodeAction(params: CodeActionParams): Promise onDocumentLinks(params: DocumentLinkParams): DocumentLink[] + sortClassLists(classLists: string[]): string[] } type ProjectConfig = { @@ -533,6 +534,7 @@ async function createProjectService( state.enabled = false refreshDiagnostics() updateCapabilities() + connection.sendNotification('@/tailwindCSS/projectReset') } async function tryInit() { @@ -541,6 +543,7 @@ async function createProjectService( } try { await init() + connection.sendNotification('@/tailwindCSS/projectInitialized') } catch (error) { resetState() showError(connection, error) @@ -1270,9 +1273,70 @@ async function createProjectService( .replace(/\d+\.\d+(%?)/g, (value, suffix) => `${Math.round(parseFloat(value))}${suffix}`), ].map((value) => ({ label: `${prefix}-[${value}]` })) }, + sortClassLists(classLists: string[]): string[] { + if (!state.jit) { + return classLists + } + + return classLists.map((classList) => { + let result = '' + let parts = classList.split(/(\s+)/) + let classes = parts.filter((_, i) => i % 2 === 0) + let whitespace = parts.filter((_, i) => i % 2 !== 0) + + if (classes[classes.length - 1] === '') { + classes.pop() + } + + let classNamesWithOrder = state.jitContext.getClassOrder + ? state.jitContext.getClassOrder(classes) + : getClassOrderPolyfill(state, classes) + + classes = classNamesWithOrder + .sort(([, a], [, z]) => { + if (a === z) return 0 + if (a === null) return -1 + if (z === null) return 1 + return bigSign(a - z) + }) + .map(([className]) => className) + + for (let i = 0; i < classes.length; i++) { + result += `${classes[i]}${whitespace[i] ?? ''}` + } + + return result + }) + }, } } +function prefixCandidate(state: State, selector: string) { + let prefix = state.config.prefix + return typeof prefix === 'function' ? prefix(selector) : prefix + selector +} + +function getClassOrderPolyfill(state: State, classes: string[]): Array<[string, bigint]> { + let parasiteUtilities = new Set([prefixCandidate(state, 'group'), prefixCandidate(state, 'peer')]) + + let classNamesWithOrder = [] + + for (let className of classes) { + let order = + state.modules.jit.generateRules + .module(new Set([className]), state.jitContext) + .sort(([a], [z]) => bigSign(z - a))[0]?.[0] ?? null + + if (order === null && parasiteUtilities.has(className)) { + order = state.jitContext.layerOrder.components + } + + classNamesWithOrder.push([className, order]) + } + + return classNamesWithOrder +} + function isObject(value: unknown): boolean { return Object.prototype.toString.call(value) === '[object Object]' } @@ -2150,6 +2214,39 @@ class TW { this.connection.onColorPresentation(this.onColorPresentation.bind(this)) this.connection.onCodeAction(this.onCodeAction.bind(this)) this.connection.onDocumentLinks(this.onDocumentLinks.bind(this)) + this.connection.onRequest(this.onRequest.bind(this)) + } + + private onRequest( + method: '@/tailwindCSS/sortSelection', + params: { uri: string; classLists: string[] } + ): { error: string } | { classLists: string[] } + private onRequest( + method: '@/tailwindCSS/getProject', + params: { uri: string } + ): { version: string } | null + private onRequest(method: string, params: any): any { + if (method === '@/tailwindCSS/sortSelection') { + let project = this.getProject({ uri: params.uri }) + if (!project) { + return { error: 'no-project' } + } + try { + return { classLists: project.sortClassLists(params.classLists) } + } catch { + return { error: 'unknown' } + } + } + + if (method === '@/tailwindCSS/getProject') { + let project = this.getProject({ uri: params.uri }) + if (!project || !project.enabled() || !project.state?.enabled) { + return null + } + return { + version: project.state.version, + } + } } private updateCapabilities() { @@ -2270,6 +2367,7 @@ class TW { } dispose(): void { + connection.sendNotification('@/tailwindCSS/projectsDestroyed') for (let [, project] of this.projects) { project.dispose() } diff --git a/packages/tailwindcss-language-server/tests/commands/commands.test.js b/packages/tailwindcss-language-server/tests/commands/commands.test.js new file mode 100644 index 0000000..a968325 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/commands/commands.test.js @@ -0,0 +1,14 @@ +import { test, expect } from 'vitest' +import { withFixture } from '../common' + +withFixture('basic', (c) => { + test.concurrent('sortSelection', async () => { + let textDocument = await c.openDocument({ text: '
' }) + let res = await c.sendRequest('@/tailwindCSS/sortSelection', { + uri: textDocument.uri, + classLists: ['sm:p-0 p-0'], + }) + + expect(res).toEqual({ classLists: ['p-0 sm:p-0'] }) + }) +}) diff --git a/packages/vscode-tailwindcss/README.md b/packages/vscode-tailwindcss/README.md index af40434..714b380 100644 --- a/packages/vscode-tailwindcss/README.md +++ b/packages/vscode-tailwindcss/README.md @@ -54,6 +54,16 @@ By default VS Code will not trigger completions when editing "string" content, f } ``` +## Extension Commands + +### `Tailwind CSS: Show Output` + +Reveal the language server log panel. This command is only available when there is an active language server instance. + +### `Tailwind CSS: Sort Selection` (pre-release) + +When a list of CSS classes is selected this command can be used to sort them in [the same order that Tailwind orders them in your CSS](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier#how-classes-are-sorted). This command is only available when the current document belongs to an active Tailwind project and the `tailwindcss` version is `3.0.0` or greater. + ## Extension Settings ### `tailwindCSS.includeLanguages` diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index 87fef32..cb00b41 100755 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -60,6 +60,11 @@ "command": "tailwindCSS.showOutput", "title": "Tailwind CSS: Show Output", "enablement": "tailwindCSS.hasOutputChannel" + }, + { + "command": "tailwindCSS.sortSelection", + "title": "Tailwind CSS: Sort Selection", + "enablement": "editorHasSelection && resourceScheme == file && tailwindCSS.activeTextEditorSupportsClassSorting" } ], "grammars": [ diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index ac8951b..9139983 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -27,6 +27,7 @@ import { SnippetString, TextEdit, TextEditorSelectionChangeKind, + Selection, } from 'vscode' import { LanguageClient, @@ -38,6 +39,7 @@ import { Disposable, } from 'vscode-languageclient/node' import { languages as defaultLanguages } from 'tailwindcss-language-service/src/util/languages' +import * as semver from 'tailwindcss-language-service/src/util/semver' import isObject from 'tailwindcss-language-service/src/util/isObject' import { dedupe, equal } from 'tailwindcss-language-service/src/util/array' import namedColors from 'color-name' @@ -123,6 +125,71 @@ async function fileContainsAtConfig(uri: Uri) { return /@config\s*['"]/.test(contents) } +function selectionsAreEqual( + aSelections: readonly Selection[], + bSelections: readonly Selection[] +): boolean { + if (aSelections.length !== bSelections.length) { + return false + } + for (let i = 0; i < aSelections.length; i++) { + if (!aSelections[i].isEqual(bSelections[i])) { + return false + } + } + return true +} + +async function getActiveTextEditorProject(): Promise<{ version: string } | null> { + if (clients.size === 0) { + return null + } + let editor = Window.activeTextEditor + if (!editor) { + return null + } + let uri = editor.document.uri + let folder = Workspace.getWorkspaceFolder(uri) + if (!folder) { + return null + } + let client = clients.get(folder.uri.toString()) + if (!client) { + return null + } + if (isExcluded(uri.fsPath, folder)) { + return null + } + try { + let project = await client.sendRequest<{ version: string } | null>('@/tailwindCSS/getProject', { + uri: uri.toString(), + }) + return project + } catch { + return null + } +} + +async function activeTextEditorSupportsClassSorting(): Promise { + let project = await getActiveTextEditorProject() + if (!project) { + return false + } + return semver.gte(project.version, '3.0.0') +} + +async function updateActiveTextEditorContext(): Promise { + commands.executeCommand( + 'setContext', + 'tailwindCSS.activeTextEditorSupportsClassSorting', + await activeTextEditorSupportsClassSorting() + ) +} + +function resetActiveTextEditorContext(): void { + commands.executeCommand('setContext', 'tailwindCSS.activeTextEditorSupportsClassSorting', false) +} + export async function activate(context: ExtensionContext) { let module = context.asAbsolutePath(path.join('dist', 'server.js')) let prod = path.join('dist', 'tailwindServer.js') @@ -142,6 +209,72 @@ export async function activate(context: ExtensionContext) { }) ) + async function sortSelection(): Promise { + let { document, selections } = Window.activeTextEditor + + if (selections.length === 0) { + return + } + + let initialSelections = selections + let folder = Workspace.getWorkspaceFolder(document.uri) + + if (clients.size === 0 || !folder || isExcluded(document.uri.fsPath, folder)) { + throw Error(`No active Tailwind project found for file ${document.uri.fsPath}`) + } + + let client = clients.get(folder.uri.toString()) + if (!client) { + throw Error(`No active Tailwind project found for file ${document.uri.fsPath}`) + } + + let result = await client.sendRequest<{ error: string } | { classLists: string[] }>( + '@/tailwindCSS/sortSelection', + { + uri: document.uri.toString(), + classLists: selections.map((selection) => document.getText(selection)), + } + ) + + if ( + Window.activeTextEditor.document !== document || + !selectionsAreEqual(initialSelections, Window.activeTextEditor.selections) + ) { + return + } + + if ('error' in result) { + throw Error( + { + 'no-project': `No active Tailwind project found for file ${document.uri.fsPath}`, + }[result.error] ?? 'An unknown error occurred.' + ) + } + + let sortedClassLists = result.classLists + Window.activeTextEditor.edit((builder) => { + for (let i = 0; i < selections.length; i++) { + builder.replace(selections[i], sortedClassLists[i]) + } + }) + } + + context.subscriptions.push( + commands.registerCommand('tailwindCSS.sortSelection', async () => { + try { + await sortSelection() + } catch (error) { + Window.showWarningMessage(`Couldn’t sort Tailwind classes: ${error.message}`) + } + }) + ) + + context.subscriptions.push( + Window.onDidChangeActiveTextEditor(async () => { + await updateActiveTextEditorContext() + }) + ) + // context.subscriptions.push( // commands.registerCommand( // 'tailwindCSS.onInsertArbitraryVariantSnippet', @@ -620,6 +753,16 @@ export async function activate(context: ExtensionContext) { client.onNotification('@/tailwindCSS/clearColors', () => clearColors()) + client.onNotification('@/tailwindCSS/projectInitialized', async () => { + await updateActiveTextEditorContext() + }) + client.onNotification('@/tailwindCSS/projectReset', async () => { + await updateActiveTextEditorContext() + }) + client.onNotification('@/tailwindCSS/projectsDestroyed', () => { + resetActiveTextEditorContext() + }) + client.onRequest('@/tailwindCSS/getDocumentSymbols', async ({ uri }) => { return commands.executeCommand( 'vscode.executeDocumentSymbolProvider',