Skip to content

Commit 1cc8e62

Browse files
authored
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
1 parent 2599da8 commit 1cc8e62

File tree

5 files changed

+271
-1
lines changed

5 files changed

+271
-1
lines changed

packages/tailwindcss-language-server/src/server.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ import { getModuleDependencies } from './util/getModuleDependencies'
7474
import assert from 'assert'
7575
// import postcssLoadConfig from 'postcss-load-config'
7676
import * as parcel from './watcher/index.js'
77-
import { generateRules } from 'tailwindcss-language-service/src/util/jit'
77+
import { bigSign } from 'tailwindcss-language-service/src/util/jit'
7878
import { getColor } from 'tailwindcss-language-service/src/util/color'
7979
import * as culori from 'culori'
8080
import namedColors from 'color-name'
@@ -195,6 +195,7 @@ interface ProjectService {
195195
onColorPresentation(params: ColorPresentationParams): Promise<ColorPresentation[]>
196196
onCodeAction(params: CodeActionParams): Promise<CodeAction[]>
197197
onDocumentLinks(params: DocumentLinkParams): DocumentLink[]
198+
sortClassLists(classLists: string[]): string[]
198199
}
199200

200201
type ProjectConfig = {
@@ -533,6 +534,7 @@ async function createProjectService(
533534
state.enabled = false
534535
refreshDiagnostics()
535536
updateCapabilities()
537+
connection.sendNotification('@/tailwindCSS/projectReset')
536538
}
537539

538540
async function tryInit() {
@@ -541,6 +543,7 @@ async function createProjectService(
541543
}
542544
try {
543545
await init()
546+
connection.sendNotification('@/tailwindCSS/projectInitialized')
544547
} catch (error) {
545548
resetState()
546549
showError(connection, error)
@@ -1270,7 +1273,68 @@ async function createProjectService(
12701273
.replace(/\d+\.\d+(%?)/g, (value, suffix) => `${Math.round(parseFloat(value))}${suffix}`),
12711274
].map((value) => ({ label: `${prefix}-[${value}]` }))
12721275
},
1276+
sortClassLists(classLists: string[]): string[] {
1277+
if (!state.jit) {
1278+
return classLists
1279+
}
1280+
1281+
return classLists.map((classList) => {
1282+
let result = ''
1283+
let parts = classList.split(/(\s+)/)
1284+
let classes = parts.filter((_, i) => i % 2 === 0)
1285+
let whitespace = parts.filter((_, i) => i % 2 !== 0)
1286+
1287+
if (classes[classes.length - 1] === '') {
1288+
classes.pop()
1289+
}
1290+
1291+
let classNamesWithOrder = state.jitContext.getClassOrder
1292+
? state.jitContext.getClassOrder(classes)
1293+
: getClassOrderPolyfill(state, classes)
1294+
1295+
classes = classNamesWithOrder
1296+
.sort(([, a], [, z]) => {
1297+
if (a === z) return 0
1298+
if (a === null) return -1
1299+
if (z === null) return 1
1300+
return bigSign(a - z)
1301+
})
1302+
.map(([className]) => className)
1303+
1304+
for (let i = 0; i < classes.length; i++) {
1305+
result += `${classes[i]}${whitespace[i] ?? ''}`
1306+
}
1307+
1308+
return result
1309+
})
1310+
},
1311+
}
1312+
}
1313+
1314+
function prefixCandidate(state: State, selector: string) {
1315+
let prefix = state.config.prefix
1316+
return typeof prefix === 'function' ? prefix(selector) : prefix + selector
1317+
}
1318+
1319+
function getClassOrderPolyfill(state: State, classes: string[]): Array<[string, bigint]> {
1320+
let parasiteUtilities = new Set([prefixCandidate(state, 'group'), prefixCandidate(state, 'peer')])
1321+
1322+
let classNamesWithOrder = []
1323+
1324+
for (let className of classes) {
1325+
let order =
1326+
state.modules.jit.generateRules
1327+
.module(new Set([className]), state.jitContext)
1328+
.sort(([a], [z]) => bigSign(z - a))[0]?.[0] ?? null
1329+
1330+
if (order === null && parasiteUtilities.has(className)) {
1331+
order = state.jitContext.layerOrder.components
1332+
}
1333+
1334+
classNamesWithOrder.push([className, order])
12731335
}
1336+
1337+
return classNamesWithOrder
12741338
}
12751339

12761340
function isObject(value: unknown): boolean {
@@ -2150,6 +2214,39 @@ class TW {
21502214
this.connection.onColorPresentation(this.onColorPresentation.bind(this))
21512215
this.connection.onCodeAction(this.onCodeAction.bind(this))
21522216
this.connection.onDocumentLinks(this.onDocumentLinks.bind(this))
2217+
this.connection.onRequest(this.onRequest.bind(this))
2218+
}
2219+
2220+
private onRequest(
2221+
method: '@/tailwindCSS/sortSelection',
2222+
params: { uri: string; classLists: string[] }
2223+
): { error: string } | { classLists: string[] }
2224+
private onRequest(
2225+
method: '@/tailwindCSS/getProject',
2226+
params: { uri: string }
2227+
): { version: string } | null
2228+
private onRequest(method: string, params: any): any {
2229+
if (method === '@/tailwindCSS/sortSelection') {
2230+
let project = this.getProject({ uri: params.uri })
2231+
if (!project) {
2232+
return { error: 'no-project' }
2233+
}
2234+
try {
2235+
return { classLists: project.sortClassLists(params.classLists) }
2236+
} catch {
2237+
return { error: 'unknown' }
2238+
}
2239+
}
2240+
2241+
if (method === '@/tailwindCSS/getProject') {
2242+
let project = this.getProject({ uri: params.uri })
2243+
if (!project || !project.enabled() || !project.state?.enabled) {
2244+
return null
2245+
}
2246+
return {
2247+
version: project.state.version,
2248+
}
2249+
}
21532250
}
21542251

21552252
private updateCapabilities() {
@@ -2270,6 +2367,7 @@ class TW {
22702367
}
22712368

22722369
dispose(): void {
2370+
connection.sendNotification('@/tailwindCSS/projectsDestroyed')
22732371
for (let [, project] of this.projects) {
22742372
project.dispose()
22752373
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { test, expect } from 'vitest'
2+
import { withFixture } from '../common'
3+
4+
withFixture('basic', (c) => {
5+
test.concurrent('sortSelection', async () => {
6+
let textDocument = await c.openDocument({ text: '<div class="sm:p-0 p-0">' })
7+
let res = await c.sendRequest('@/tailwindCSS/sortSelection', {
8+
uri: textDocument.uri,
9+
classLists: ['sm:p-0 p-0'],
10+
})
11+
12+
expect(res).toEqual({ classLists: ['p-0 sm:p-0'] })
13+
})
14+
})

packages/vscode-tailwindcss/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ By default VS Code will not trigger completions when editing "string" content, f
5454
}
5555
```
5656

57+
## Extension Commands
58+
59+
### `Tailwind CSS: Show Output`
60+
61+
Reveal the language server log panel. This command is only available when there is an active language server instance.
62+
63+
### `Tailwind CSS: Sort Selection` (pre-release)
64+
65+
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.
66+
5767
## Extension Settings
5868

5969
### `tailwindCSS.includeLanguages`

packages/vscode-tailwindcss/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@
6060
"command": "tailwindCSS.showOutput",
6161
"title": "Tailwind CSS: Show Output",
6262
"enablement": "tailwindCSS.hasOutputChannel"
63+
},
64+
{
65+
"command": "tailwindCSS.sortSelection",
66+
"title": "Tailwind CSS: Sort Selection",
67+
"enablement": "editorHasSelection && resourceScheme == file && tailwindCSS.activeTextEditorSupportsClassSorting"
6368
}
6469
],
6570
"grammars": [

packages/vscode-tailwindcss/src/extension.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
SnippetString,
2828
TextEdit,
2929
TextEditorSelectionChangeKind,
30+
Selection,
3031
} from 'vscode'
3132
import {
3233
LanguageClient,
@@ -38,6 +39,7 @@ import {
3839
Disposable,
3940
} from 'vscode-languageclient/node'
4041
import { languages as defaultLanguages } from 'tailwindcss-language-service/src/util/languages'
42+
import * as semver from 'tailwindcss-language-service/src/util/semver'
4143
import isObject from 'tailwindcss-language-service/src/util/isObject'
4244
import { dedupe, equal } from 'tailwindcss-language-service/src/util/array'
4345
import namedColors from 'color-name'
@@ -123,6 +125,71 @@ async function fileContainsAtConfig(uri: Uri) {
123125
return /@config\s*['"]/.test(contents)
124126
}
125127

128+
function selectionsAreEqual(
129+
aSelections: readonly Selection[],
130+
bSelections: readonly Selection[]
131+
): boolean {
132+
if (aSelections.length !== bSelections.length) {
133+
return false
134+
}
135+
for (let i = 0; i < aSelections.length; i++) {
136+
if (!aSelections[i].isEqual(bSelections[i])) {
137+
return false
138+
}
139+
}
140+
return true
141+
}
142+
143+
async function getActiveTextEditorProject(): Promise<{ version: string } | null> {
144+
if (clients.size === 0) {
145+
return null
146+
}
147+
let editor = Window.activeTextEditor
148+
if (!editor) {
149+
return null
150+
}
151+
let uri = editor.document.uri
152+
let folder = Workspace.getWorkspaceFolder(uri)
153+
if (!folder) {
154+
return null
155+
}
156+
let client = clients.get(folder.uri.toString())
157+
if (!client) {
158+
return null
159+
}
160+
if (isExcluded(uri.fsPath, folder)) {
161+
return null
162+
}
163+
try {
164+
let project = await client.sendRequest<{ version: string } | null>('@/tailwindCSS/getProject', {
165+
uri: uri.toString(),
166+
})
167+
return project
168+
} catch {
169+
return null
170+
}
171+
}
172+
173+
async function activeTextEditorSupportsClassSorting(): Promise<boolean> {
174+
let project = await getActiveTextEditorProject()
175+
if (!project) {
176+
return false
177+
}
178+
return semver.gte(project.version, '3.0.0')
179+
}
180+
181+
async function updateActiveTextEditorContext(): Promise<void> {
182+
commands.executeCommand(
183+
'setContext',
184+
'tailwindCSS.activeTextEditorSupportsClassSorting',
185+
await activeTextEditorSupportsClassSorting()
186+
)
187+
}
188+
189+
function resetActiveTextEditorContext(): void {
190+
commands.executeCommand('setContext', 'tailwindCSS.activeTextEditorSupportsClassSorting', false)
191+
}
192+
126193
export async function activate(context: ExtensionContext) {
127194
let module = context.asAbsolutePath(path.join('dist', 'server.js'))
128195
let prod = path.join('dist', 'tailwindServer.js')
@@ -142,6 +209,72 @@ export async function activate(context: ExtensionContext) {
142209
})
143210
)
144211

212+
async function sortSelection(): Promise<void> {
213+
let { document, selections } = Window.activeTextEditor
214+
215+
if (selections.length === 0) {
216+
return
217+
}
218+
219+
let initialSelections = selections
220+
let folder = Workspace.getWorkspaceFolder(document.uri)
221+
222+
if (clients.size === 0 || !folder || isExcluded(document.uri.fsPath, folder)) {
223+
throw Error(`No active Tailwind project found for file ${document.uri.fsPath}`)
224+
}
225+
226+
let client = clients.get(folder.uri.toString())
227+
if (!client) {
228+
throw Error(`No active Tailwind project found for file ${document.uri.fsPath}`)
229+
}
230+
231+
let result = await client.sendRequest<{ error: string } | { classLists: string[] }>(
232+
'@/tailwindCSS/sortSelection',
233+
{
234+
uri: document.uri.toString(),
235+
classLists: selections.map((selection) => document.getText(selection)),
236+
}
237+
)
238+
239+
if (
240+
Window.activeTextEditor.document !== document ||
241+
!selectionsAreEqual(initialSelections, Window.activeTextEditor.selections)
242+
) {
243+
return
244+
}
245+
246+
if ('error' in result) {
247+
throw Error(
248+
{
249+
'no-project': `No active Tailwind project found for file ${document.uri.fsPath}`,
250+
}[result.error] ?? 'An unknown error occurred.'
251+
)
252+
}
253+
254+
let sortedClassLists = result.classLists
255+
Window.activeTextEditor.edit((builder) => {
256+
for (let i = 0; i < selections.length; i++) {
257+
builder.replace(selections[i], sortedClassLists[i])
258+
}
259+
})
260+
}
261+
262+
context.subscriptions.push(
263+
commands.registerCommand('tailwindCSS.sortSelection', async () => {
264+
try {
265+
await sortSelection()
266+
} catch (error) {
267+
Window.showWarningMessage(`Couldn’t sort Tailwind classes: ${error.message}`)
268+
}
269+
})
270+
)
271+
272+
context.subscriptions.push(
273+
Window.onDidChangeActiveTextEditor(async () => {
274+
await updateActiveTextEditorContext()
275+
})
276+
)
277+
145278
// context.subscriptions.push(
146279
// commands.registerCommand(
147280
// 'tailwindCSS.onInsertArbitraryVariantSnippet',
@@ -620,6 +753,16 @@ export async function activate(context: ExtensionContext) {
620753

621754
client.onNotification('@/tailwindCSS/clearColors', () => clearColors())
622755

756+
client.onNotification('@/tailwindCSS/projectInitialized', async () => {
757+
await updateActiveTextEditorContext()
758+
})
759+
client.onNotification('@/tailwindCSS/projectReset', async () => {
760+
await updateActiveTextEditorContext()
761+
})
762+
client.onNotification('@/tailwindCSS/projectsDestroyed', () => {
763+
resetActiveTextEditorContext()
764+
})
765+
623766
client.onRequest('@/tailwindCSS/getDocumentSymbols', async ({ uri }) => {
624767
return commands.executeCommand<SymbolInformation[]>(
625768
'vscode.executeDocumentSymbolProvider',

0 commit comments

Comments
 (0)