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
master
Brad Cornes 2023-08-31 15:53:20 +01:00 committed by GitHub
parent 2599da8541
commit 1cc8e62da3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 271 additions and 1 deletions

View File

@ -74,7 +74,7 @@ import { getModuleDependencies } from './util/getModuleDependencies'
import assert from 'assert' import assert from 'assert'
// import postcssLoadConfig from 'postcss-load-config' // import postcssLoadConfig from 'postcss-load-config'
import * as parcel from './watcher/index.js' 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 { getColor } from 'tailwindcss-language-service/src/util/color'
import * as culori from 'culori' import * as culori from 'culori'
import namedColors from 'color-name' import namedColors from 'color-name'
@ -195,6 +195,7 @@ interface ProjectService {
onColorPresentation(params: ColorPresentationParams): Promise<ColorPresentation[]> onColorPresentation(params: ColorPresentationParams): Promise<ColorPresentation[]>
onCodeAction(params: CodeActionParams): Promise<CodeAction[]> onCodeAction(params: CodeActionParams): Promise<CodeAction[]>
onDocumentLinks(params: DocumentLinkParams): DocumentLink[] onDocumentLinks(params: DocumentLinkParams): DocumentLink[]
sortClassLists(classLists: string[]): string[]
} }
type ProjectConfig = { type ProjectConfig = {
@ -533,6 +534,7 @@ async function createProjectService(
state.enabled = false state.enabled = false
refreshDiagnostics() refreshDiagnostics()
updateCapabilities() updateCapabilities()
connection.sendNotification('@/tailwindCSS/projectReset')
} }
async function tryInit() { async function tryInit() {
@ -541,6 +543,7 @@ async function createProjectService(
} }
try { try {
await init() await init()
connection.sendNotification('@/tailwindCSS/projectInitialized')
} catch (error) { } catch (error) {
resetState() resetState()
showError(connection, error) showError(connection, error)
@ -1270,9 +1273,70 @@ async function createProjectService(
.replace(/\d+\.\d+(%?)/g, (value, suffix) => `${Math.round(parseFloat(value))}${suffix}`), .replace(/\d+\.\d+(%?)/g, (value, suffix) => `${Math.round(parseFloat(value))}${suffix}`),
].map((value) => ({ label: `${prefix}-[${value}]` })) ].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 { function isObject(value: unknown): boolean {
return Object.prototype.toString.call(value) === '[object Object]' return Object.prototype.toString.call(value) === '[object Object]'
} }
@ -2150,6 +2214,39 @@ class TW {
this.connection.onColorPresentation(this.onColorPresentation.bind(this)) this.connection.onColorPresentation(this.onColorPresentation.bind(this))
this.connection.onCodeAction(this.onCodeAction.bind(this)) this.connection.onCodeAction(this.onCodeAction.bind(this))
this.connection.onDocumentLinks(this.onDocumentLinks.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() { private updateCapabilities() {
@ -2270,6 +2367,7 @@ class TW {
} }
dispose(): void { dispose(): void {
connection.sendNotification('@/tailwindCSS/projectsDestroyed')
for (let [, project] of this.projects) { for (let [, project] of this.projects) {
project.dispose() project.dispose()
} }

View File

@ -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: '<div class="sm:p-0 p-0">' })
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'] })
})
})

View File

@ -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 ## Extension Settings
### `tailwindCSS.includeLanguages` ### `tailwindCSS.includeLanguages`

View File

@ -60,6 +60,11 @@
"command": "tailwindCSS.showOutput", "command": "tailwindCSS.showOutput",
"title": "Tailwind CSS: Show Output", "title": "Tailwind CSS: Show Output",
"enablement": "tailwindCSS.hasOutputChannel" "enablement": "tailwindCSS.hasOutputChannel"
},
{
"command": "tailwindCSS.sortSelection",
"title": "Tailwind CSS: Sort Selection",
"enablement": "editorHasSelection && resourceScheme == file && tailwindCSS.activeTextEditorSupportsClassSorting"
} }
], ],
"grammars": [ "grammars": [

View File

@ -27,6 +27,7 @@ import {
SnippetString, SnippetString,
TextEdit, TextEdit,
TextEditorSelectionChangeKind, TextEditorSelectionChangeKind,
Selection,
} from 'vscode' } from 'vscode'
import { import {
LanguageClient, LanguageClient,
@ -38,6 +39,7 @@ import {
Disposable, Disposable,
} from 'vscode-languageclient/node' } from 'vscode-languageclient/node'
import { languages as defaultLanguages } from 'tailwindcss-language-service/src/util/languages' 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 isObject from 'tailwindcss-language-service/src/util/isObject'
import { dedupe, equal } from 'tailwindcss-language-service/src/util/array' import { dedupe, equal } from 'tailwindcss-language-service/src/util/array'
import namedColors from 'color-name' import namedColors from 'color-name'
@ -123,6 +125,71 @@ async function fileContainsAtConfig(uri: Uri) {
return /@config\s*['"]/.test(contents) 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<boolean> {
let project = await getActiveTextEditorProject()
if (!project) {
return false
}
return semver.gte(project.version, '3.0.0')
}
async function updateActiveTextEditorContext(): Promise<void> {
commands.executeCommand(
'setContext',
'tailwindCSS.activeTextEditorSupportsClassSorting',
await activeTextEditorSupportsClassSorting()
)
}
function resetActiveTextEditorContext(): void {
commands.executeCommand('setContext', 'tailwindCSS.activeTextEditorSupportsClassSorting', false)
}
export async function activate(context: ExtensionContext) { export async function activate(context: ExtensionContext) {
let module = context.asAbsolutePath(path.join('dist', 'server.js')) let module = context.asAbsolutePath(path.join('dist', 'server.js'))
let prod = path.join('dist', 'tailwindServer.js') let prod = path.join('dist', 'tailwindServer.js')
@ -142,6 +209,72 @@ export async function activate(context: ExtensionContext) {
}) })
) )
async function sortSelection(): Promise<void> {
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(`Couldnt sort Tailwind classes: ${error.message}`)
}
})
)
context.subscriptions.push(
Window.onDidChangeActiveTextEditor(async () => {
await updateActiveTextEditorContext()
})
)
// context.subscriptions.push( // context.subscriptions.push(
// commands.registerCommand( // commands.registerCommand(
// 'tailwindCSS.onInsertArbitraryVariantSnippet', // 'tailwindCSS.onInsertArbitraryVariantSnippet',
@ -620,6 +753,16 @@ export async function activate(context: ExtensionContext) {
client.onNotification('@/tailwindCSS/clearColors', () => clearColors()) 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 }) => { client.onRequest('@/tailwindCSS/getDocumentSymbols', async ({ uri }) => {
return commands.executeCommand<SymbolInformation[]>( return commands.executeCommand<SymbolInformation[]>(
'vscode.executeDocumentSymbolProvider', 'vscode.executeDocumentSymbolProvider',