Skip to content

Add "Sort Selection" command #851

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion packages/tailwindcss-language-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -195,6 +195,7 @@ interface ProjectService {
onColorPresentation(params: ColorPresentationParams): Promise<ColorPresentation[]>
onCodeAction(params: CodeActionParams): Promise<CodeAction[]>
onDocumentLinks(params: DocumentLinkParams): DocumentLink[]
sortClassLists(classLists: string[]): string[]
}

type ProjectConfig = {
Expand Down Expand Up @@ -533,6 +534,7 @@ async function createProjectService(
state.enabled = false
refreshDiagnostics()
updateCapabilities()
connection.sendNotification('@/tailwindCSS/projectReset')
}

async function tryInit() {
Expand All @@ -541,6 +543,7 @@ async function createProjectService(
}
try {
await init()
connection.sendNotification('@/tailwindCSS/projectInitialized')
} catch (error) {
resetState()
showError(connection, error)
Expand Down Expand Up @@ -1270,7 +1273,68 @@ 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 {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -2270,6 +2367,7 @@ class TW {
}

dispose(): void {
connection.sendNotification('@/tailwindCSS/projectsDestroyed')
for (let [, project] of this.projects) {
project.dispose()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'] })
})
})
10 changes: 10 additions & 0 deletions packages/vscode-tailwindcss/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
5 changes: 5 additions & 0 deletions packages/vscode-tailwindcss/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
143 changes: 143 additions & 0 deletions packages/vscode-tailwindcss/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
SnippetString,
TextEdit,
TextEditorSelectionChangeKind,
Selection,
} from 'vscode'
import {
LanguageClient,
Expand All @@ -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'
Expand Down Expand Up @@ -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<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) {
let module = context.asAbsolutePath(path.join('dist', 'server.js'))
let prod = path.join('dist', 'tailwindServer.js')
Expand All @@ -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(`Couldn’t sort Tailwind classes: ${error.message}`)
}
})
)

context.subscriptions.push(
Window.onDidChangeActiveTextEditor(async () => {
await updateActiveTextEditorContext()
})
)

// context.subscriptions.push(
// commands.registerCommand(
// 'tailwindCSS.onInsertArbitraryVariantSnippet',
Expand Down Expand Up @@ -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<SymbolInformation[]>(
'vscode.executeDocumentSymbolProvider',
Expand Down