Skip to content

Commit f2a83cc

Browse files
committed
feat: add extract to jsx component refactoring and display it instead of extract into function code actions when range within jsx
update docs
1 parent 67acaf1 commit f2a83cc

File tree

4 files changed

+142
-14
lines changed

4 files changed

+142
-14
lines changed

README.MD

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ TOC:
99
- [Minor Useful Features](#minor-useful-features)
1010
- [Method Snippets](#method-snippets)
1111
- [Auto Imports](#auto-imports)
12+
- [Type Driven Completions](#type-driven-completions)
1213
- [Rename Features](#rename-features)
1314
- [Special Commands List](#special-commands-list)
1415
- [Contributed Code Actions](#contributed-code-actions)
@@ -378,7 +379,9 @@ Another options that is accepted is
378379
379380
### Go to / Select Nodes by Kind
380381
381-
Use cases: search excluding comments, search & replace only within strings, find interested JSX attribute node
382+
Extremely powerful command that allows you to leverage AST knowledge of opened file.
383+
384+
Use cases: select all comments to exclude searching in comments. Or search & replace only within strings / find interested JSX attribute node.
382385
383386
## Contributed Code Actions
384387

typescript/src/codeActions/decorateProxy.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@ export default (proxy: ts.LanguageService, languageService: ts.LanguageService,
77
proxy.getApplicableRefactors = (fileName, positionOrRange, preferences) => {
88
let prior = languageService.getApplicableRefactors(fileName, positionOrRange, preferences)
99

10+
const program = languageService.getProgram()!
11+
const sourceFile = program.getSourceFile(fileName)!
1012
processApplicableRefactors(
1113
prior.find(r => r.description === 'Extract function'),
1214
c,
15+
positionOrRange,
16+
sourceFile,
1317
)
1418

1519
if (c('markTsCodeActions.enable')) prior = prior.map(item => ({ ...item, description: `🔵 ${item.description}` }))
1620

17-
const program = languageService.getProgram()
18-
const sourceFile = program!.getSourceFile(fileName)!
1921
const { info: refactorInfo } = getCodeActions(sourceFile, positionOrRange, languageService, languageServiceHost)
2022
if (refactorInfo) prior = [...prior, refactorInfo]
2123

typescript/src/codeActions/functionExtractors.ts

Lines changed: 130 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,75 @@
11
import { GetConfig } from '../types'
2-
import { dedentString, findChildContainingPositionMaxDepth } from '../utils'
2+
import {
3+
createDummySourceFile,
4+
dedentString,
5+
findChildContainingExactPosition,
6+
findChildContainingPosition,
7+
findChildContainingPositionMaxDepth,
8+
} from '../utils'
39

4-
export const processApplicableRefactors = (refactor: ts.ApplicableRefactorInfo | undefined, c: GetConfig) => {
10+
export const processApplicableRefactors = (
11+
refactor: ts.ApplicableRefactorInfo | undefined,
12+
c: GetConfig,
13+
posOrRange: number | ts.TextRange,
14+
sourceFile: ts.SourceFile,
15+
) => {
516
if (!refactor) return
617
const functionExtractors = refactor?.actions.filter(({ notApplicableReason }) => !notApplicableReason)
718
if (functionExtractors?.length) {
819
const kind = functionExtractors[0]!.kind!
920
const blockScopeRefactor = functionExtractors.find(e => e.description.startsWith('Extract to inner function in'))
21+
const addArrowCodeActions: ts.RefactorActionInfo[] = []
1022
if (blockScopeRefactor) {
11-
refactor!.actions.push({
23+
addArrowCodeActions.push({
1224
description: 'Extract to arrow function above',
1325
kind,
1426
name: `${blockScopeRefactor.name}_local_arrow`,
1527
})
1628
}
29+
let addExtractToJsxRefactor = false
1730
const globalScopeRefactor = functionExtractors.find(e =>
1831
['Extract to function in global scope', 'Extract to function in module scope'].includes(e.description),
1932
)
2033
if (globalScopeRefactor) {
21-
refactor!.actions.push({
34+
addArrowCodeActions.push({
2235
description: 'Extract to arrow function in global scope above',
2336
kind,
2437
name: `${globalScopeRefactor.name}_arrow`,
2538
})
39+
40+
addExtractToJsxRefactor = typeof posOrRange !== 'number' && !!possiblyAddExtractToJsx(sourceFile, posOrRange.pos, posOrRange.end)
2641
}
42+
43+
if (addExtractToJsxRefactor) {
44+
refactor.actions = refactor.actions.filter(action => !action.name.startsWith('function_scope'))
45+
refactor.actions.push({
46+
description: 'Extract to JSX component',
47+
kind: 'refactor.extract.jsx',
48+
name: `${globalScopeRefactor!.name}_jsx`,
49+
})
50+
return
51+
}
52+
53+
refactor.actions.push(...addArrowCodeActions)
54+
}
55+
}
56+
57+
const possiblyAddExtractToJsx = (sourceFile: ts.SourceFile, start: number, end: number): void | true => {
58+
if (start === end) return
59+
let node1 = findChildContainingPosition(ts, sourceFile, start)
60+
const node2 = findChildContainingExactPosition(sourceFile, end)
61+
if (!node1 || !node2) return
62+
if (ts.isIdentifier(node1)) node1 = node1.parent
63+
const nodeStart = node1.pos + node1.getLeadingTriviaWidth()
64+
let validPosition = false
65+
if (node1 === node2 && ts.isJsxSelfClosingElement(node1) && start === nodeStart && end === node1.end) {
66+
validPosition = true
67+
}
68+
if (ts.isJsxOpeningElement(node1) && ts.isJsxClosingElement(node2) && node2.parent.openingElement === node1 && start === nodeStart && end === node2.end) {
69+
validPosition = true
2770
}
71+
if (!validPosition) return
72+
return true
2873
}
2974

3075
export const handleFunctionRefactorEdits = (
@@ -37,8 +82,8 @@ export const handleFunctionRefactorEdits = (
3782
refactorName: string,
3883
preferences: ts.UserPreferences | undefined,
3984
): ts.RefactorEditInfo | undefined => {
40-
if (!actionName.endsWith('_arrow')) return
41-
const originalAcitonName = actionName.replace('_local_arrow', '').replace('_arrow', '')
85+
if (!actionName.endsWith('_arrow') && !actionName.endsWith('_jsx')) return
86+
const originalAcitonName = actionName.replace('_local_arrow', '').replace('_arrow', '').replace('_jsx', '')
4287
const { edits: originalEdits, renameFilename } = languageService.getEditsForRefactor(
4388
fileName,
4489
formatOptions,
@@ -51,6 +96,43 @@ export const handleFunctionRefactorEdits = (
5196
const { textChanges } = originalEdits[0]!
5297
const functionChange = textChanges.at(-1)!
5398
const oldFunctionText = functionChange.newText
99+
const sourceFile = languageService.getProgram()!.getSourceFile(fileName)!
100+
if (actionName.endsWith('_jsx')) {
101+
const lines = oldFunctionText.trimStart().split('\n')
102+
const oldFunctionSignature = lines[0]!
103+
const componentName = tsFull.getUniqueName('ExtractedComponent', sourceFile as FullSourceFile)
104+
const newFunctionSignature = changeArgumentsToDestructured(oldFunctionSignature, formatOptions, sourceFile, componentName)
105+
106+
const insertChange = textChanges.at(-2)!
107+
let args = insertChange.newText.slice(1, -2)
108+
args = args.slice(args.indexOf('(') + 1)
109+
const fileEdits = [
110+
{
111+
fileName,
112+
textChanges: [
113+
...textChanges.slice(0, -2),
114+
{
115+
...insertChange,
116+
newText: `<${componentName} ${args
117+
.split(', ')
118+
.map(identifierText => `${identifierText}={${identifierText}}`)
119+
.join(' ')} />`,
120+
},
121+
{
122+
span: functionChange.span,
123+
newText: oldFunctionText.match(/\s*/)![0] + newFunctionSignature.slice(0, -2) + '\n' + lines.slice(1).join('\n'),
124+
},
125+
],
126+
},
127+
]
128+
return {
129+
edits: fileEdits,
130+
renameFilename,
131+
renameLocation: insertChange.span.start + 1,
132+
// renameLocation: tsFull.getRenameLocation(fileEdits, fileName, componentName, /*preferLastLocation*/ false),
133+
}
134+
}
135+
54136
const functionName = oldFunctionText.slice(oldFunctionText.indexOf('function ') + 'function '.length, oldFunctionText.indexOf('('))
55137
functionChange.newText = oldFunctionText
56138
.replace(/function /, 'const ')
@@ -73,11 +155,7 @@ export const handleFunctionRefactorEdits = (
73155

74156
// global scope
75157
if (!isLocal) {
76-
const lastNode = findChildContainingPositionMaxDepth(
77-
languageService.getProgram()!.getSourceFile(fileName)!,
78-
typeof positionOrRange === 'object' ? positionOrRange.pos : positionOrRange,
79-
2,
80-
)
158+
const lastNode = findChildContainingPositionMaxDepth(sourceFile, typeof positionOrRange === 'object' ? positionOrRange.pos : positionOrRange, 2)
81159
if (lastNode) {
82160
const pos = lastNode.pos + (lastNode.getFullText().match(/^\s+/)?.[0]?.length ?? 1) - 1
83161
functionChange.span.start = pos
@@ -95,3 +173,44 @@ export const handleFunctionRefactorEdits = (
95173
renameFilename,
96174
}
97175
}
176+
177+
export function changeArgumentsToDestructured(
178+
oldFunctionSignature: string,
179+
formatOptions: ts.FormatCodeSettings,
180+
sourceFile: ts.SourceFile,
181+
componentName: string,
182+
) {
183+
const { factory } = ts
184+
const dummySourceFile = createDummySourceFile(oldFunctionSignature)
185+
const functionDeclaration = dummySourceFile.statements[0] as ts.FunctionDeclaration
186+
const { parameters, type: returnType } = functionDeclaration
187+
const paramNames = parameters.map(p => p.name as ts.Identifier)
188+
const paramTypes = parameters.map(p => p.type!)
189+
const newFunction = factory.createFunctionDeclaration(
190+
undefined,
191+
undefined,
192+
componentName,
193+
undefined,
194+
[
195+
factory.createParameterDeclaration(
196+
undefined,
197+
undefined,
198+
factory.createObjectBindingPattern(paramNames.map(paramName => factory.createBindingElement(undefined, undefined, paramName))),
199+
undefined,
200+
factory.createTypeLiteralNode(
201+
paramNames.map((paramName, i) => {
202+
const type = paramTypes[i]!
203+
return factory.createPropertySignature(undefined, paramName, undefined, type)
204+
}),
205+
),
206+
),
207+
],
208+
returnType,
209+
factory.createBlock([]),
210+
)
211+
// const changesTracker = getChangesTracker(formatOptions)
212+
// changesTracker.insertNodeAt(sourceFile, 0, newFunction)
213+
// const newFunctionText = changesTracker.getChanges()[0]!.textChanges[0]!;
214+
const newFunctionText = ts.createPrinter().printNode(ts.EmitHint.Unspecified, newFunction, sourceFile)
215+
return newFunctionText
216+
}

typescript/src/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ const wordRangeAtPos = (text: string, position: number) => {
235235

236236
type GetIs<T> = T extends (elem: any) => elem is infer T ? T : never
237237

238+
export const createDummySourceFile = (code: string) => {
239+
return ts.createSourceFile('test.ts', code, ts.ScriptTarget.ESNext, false)
240+
}
241+
238242
export function approveCast<T2 extends Array<(node: ts.Node) => node is ts.Node>>(node: ts.Node | undefined, ...oneOfTest: T2): node is GetIs<T2[number]> {
239243
if (node === undefined) return false
240244
if (!oneOfTest) throw new Error('Tests are not provided')

0 commit comments

Comments
 (0)