Skip to content

Commit 848259c

Browse files
committed
refactor: move code extension actions provider into its own file
refactor apiCommands to make more readable & useful
1 parent b5de952 commit 848259c

File tree

4 files changed

+279
-269
lines changed

4 files changed

+279
-269
lines changed

src/apiCommands.ts

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,58 @@ import { getExtensionCommandId } from 'vscode-framework'
33
import { passthroughExposedApiCommands, TriggerCharacterCommand } from '../typescript/src/ipcTypes'
44
import { sendCommand } from './sendCommand'
55

6-
export default () => {
7-
/** @unique */
8-
const cacheableCommands: Set<(typeof passthroughExposedApiCommands)[number]> = new Set(['getNodePath', 'getSpanOfEnclosingComment', 'getNodeAtPosition'])
9-
const operationsCache = new Map<string, { key: string; data; time?: number }>()
10-
const sharedRequest = async (type: TriggerCharacterCommand, { offset, relativeOffset = 0, document, position }: RequestOptions) => {
11-
if (position && offset) throw new Error('Only position or offset parameter can be provided')
12-
if (document && !offset && !position) throw new Error('When custom document is provided, offset or position must be provided')
6+
type RequestOptions = Partial<{
7+
/**
8+
* Should be rarely overrided, this document must be part of opened project
9+
* If specificed, offset or position must be provided too
10+
*/
11+
document: vscode.TextDocument
12+
offset: number
13+
relativeOffset: number
14+
position: vscode.Position
15+
}>
1316

14-
const { activeTextEditor } = vscode.window
15-
document ??= activeTextEditor?.document
16-
if (!document) return
17-
if (!position) offset ??= document.offsetAt(activeTextEditor!.selection.active) + relativeOffset
18-
const requestOffset = offset ?? document.offsetAt(position!)
19-
const requestPos = position ?? document.positionAt(offset!)
20-
const getData = async () => sendCommand(type, { document: document!, position: requestPos })
21-
const CACHE_UNDEFINED_TIMEOUT = 1000
22-
if (cacheableCommands.has(type as any)) {
23-
const cacheEntry = operationsCache.get(type)
24-
const operationKey = `${document.uri.toString()}:${document.version}:${requestOffset}`
25-
if (cacheEntry?.key === operationKey && cacheEntry?.time && Date.now() - cacheEntry.time < CACHE_UNDEFINED_TIMEOUT) {
26-
return cacheEntry.data
27-
}
17+
/** @unique */
18+
const cacheableCommands: Set<(typeof passthroughExposedApiCommands)[number]> = new Set(['getNodePath', 'getSpanOfEnclosingComment', 'getNodeAtPosition'])
19+
const operationsCache = new Map<string, { key: string; data; time?: number }>()
20+
export const sharedApiRequest = async (type: TriggerCharacterCommand, { offset, relativeOffset = 0, document, position }: RequestOptions) => {
21+
if (position && offset) throw new Error('Only position or offset parameter can be provided')
22+
if (document && !offset && !position) throw new Error('When custom document is provided, offset or position must be provided')
2823

29-
const data = getData()
30-
// intentionally storing data only per one offset because it was created for this specific case:
31-
// extension 1 completion provider requests API data
32-
// at the same time:
33-
// extension 2 completion provider requests API data at the same document and position
34-
// and so on
35-
operationsCache.set(type, { key: operationKey, data, time: data === undefined ? Date.now() : undefined })
36-
if (type === 'getNodePath') {
37-
operationsCache.set('getNodeAtPosition', { key: operationKey, data: data.then((path: any) => path?.[path.length - 1]) })
38-
}
24+
const { activeTextEditor } = vscode.window
25+
document ??= activeTextEditor?.document
26+
if (!document) return
27+
if (!position) offset ??= document.offsetAt(activeTextEditor!.selection.active) + relativeOffset
28+
const requestOffset = offset ?? document.offsetAt(position!)
29+
const requestPos = position ?? document.positionAt(offset!)
30+
const getData = async () => sendCommand(type, { document: document!, position: requestPos })
31+
const CACHE_UNDEFINED_TIMEOUT = 1000
32+
if (cacheableCommands.has(type as any)) {
33+
const cacheEntry = operationsCache.get(type)
34+
const operationKey = `${document.uri.toString()}:${document.version}:${requestOffset}`
35+
if (cacheEntry?.key === operationKey && cacheEntry?.time && Date.now() - cacheEntry.time < CACHE_UNDEFINED_TIMEOUT) {
36+
return cacheEntry.data
37+
}
3938

40-
return data
39+
const data = getData()
40+
// intentionally storing data only per one offset because it was created for this specific case:
41+
// extension 1 completion provider requests API data
42+
// at the same time:
43+
// extension 2 completion provider requests API data at the same document and position
44+
// and so on
45+
operationsCache.set(type, { key: operationKey, data, time: data === undefined ? Date.now() : undefined })
46+
if (type === 'getNodePath') {
47+
operationsCache.set('getNodeAtPosition', { key: operationKey, data: data.then((path: any) => path?.[path.length - 1]) })
4148
}
4249

43-
return getData()
50+
return data
4451
}
4552

46-
type RequestOptions = Partial<{
47-
/**
48-
* Should be rarely overrided, this document must be part of opened project
49-
* If specificed, offset or position must be provided too
50-
*/
51-
document: vscode.TextDocument
52-
offset: number
53-
relativeOffset: number
54-
position: vscode.Position
55-
}>
56-
for (const cmd of passthroughExposedApiCommands)
57-
vscode.commands.registerCommand(getExtensionCommandId(cmd as never), async (options: RequestOptions = {}) => sharedRequest(cmd, options))
53+
return getData()
54+
}
55+
56+
export default () => {
57+
for (const cmd of passthroughExposedApiCommands) {
58+
vscode.commands.registerCommand(getExtensionCommandId(cmd as never), async (options: RequestOptions = {}) => sharedApiRequest(cmd, options))
59+
}
5860
}

src/codeActionProvider.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import * as vscode from 'vscode'
2+
import { relative, join } from 'path-browserify'
3+
import { defaultJsSupersetLangsWithVue } from '@zardoy/vscode-utils/build/langs'
4+
import { partition } from 'lodash'
5+
import { registerExtensionCommand, showQuickPick, getExtensionSetting, getExtensionCommandId } from 'vscode-framework'
6+
import { compact } from '@zardoy/utils'
7+
import { RequestResponseTypes, RequestOptionsTypes } from '../typescript/src/ipcTypes'
8+
import { sendCommand } from './sendCommand'
9+
import {
10+
pickFileWithQuickPick,
11+
getTsLikePath,
12+
tsRangeToVscode,
13+
tsTextChangesToVscodeTextEdits,
14+
vscodeRangeToTs,
15+
tsTextChangesToVscodeSnippetTextEdits,
16+
} from './util'
17+
18+
// extended and interactive code actions
19+
export default () => {
20+
type ExtendedCodeAction = vscode.CodeAction & { document: vscode.TextDocument; requestRange: vscode.Range }
21+
22+
// most probably will be moved to ts-code-actions extension
23+
vscode.languages.registerCodeActionsProvider(defaultJsSupersetLangsWithVue, {
24+
async provideCodeActions(document, range, context, token) {
25+
if (document !== vscode.window.activeTextEditor?.document || !getExtensionSetting('enablePlugin')) {
26+
return
27+
}
28+
29+
if (context.only?.contains(vscode.CodeActionKind.SourceFixAll)) {
30+
const fixAllEdits = await sendCommand<RequestResponseTypes['getFixAllEdits']>('getFixAllEdits', {
31+
document,
32+
})
33+
if (!fixAllEdits || token.isCancellationRequested) return
34+
const edit = new vscode.WorkspaceEdit()
35+
edit.set(document.uri, tsTextChangesToVscodeTextEdits(document, fixAllEdits))
36+
return [
37+
{
38+
title: '[essentials] Fix all TypeScript',
39+
kind: vscode.CodeActionKind.SourceFixAll,
40+
edit,
41+
},
42+
]
43+
}
44+
45+
if (context.triggerKind !== vscode.CodeActionTriggerKind.Invoke) return
46+
const result = await getPossibleTwoStepRefactorings(range)
47+
if (!result) return
48+
const { turnArrayIntoObject, moveToExistingFile, extendedCodeActions } = result
49+
const codeActions: vscode.CodeAction[] = []
50+
const getCommand = (arg): vscode.Command | undefined => ({
51+
title: '',
52+
command: getExtensionCommandId('applyRefactor' as any),
53+
arguments: [arg],
54+
})
55+
56+
if (turnArrayIntoObject) {
57+
codeActions.push({
58+
title: `Turn array into object (${turnArrayIntoObject.totalCount} elements)`,
59+
command: getCommand({ turnArrayIntoObject }),
60+
kind: vscode.CodeActionKind.RefactorRewrite,
61+
})
62+
}
63+
64+
if (moveToExistingFile) {
65+
// codeActions.push({
66+
// title: `Move to existing file`,
67+
// command: getCommand({ moveToExistingFile }),
68+
// kind: vscode.CodeActionKind.Refactor.append('move'),
69+
// })
70+
}
71+
72+
codeActions.push(
73+
...compact(
74+
extendedCodeActions.map(({ title, kind, codes }): ExtendedCodeAction | undefined => {
75+
let diagnostics: vscode.Diagnostic[] | undefined
76+
if (codes) {
77+
diagnostics = context.diagnostics.filter(({ source, code }) => {
78+
if (source !== 'ts' || !code) return
79+
const codeNumber = +(typeof code === 'object' ? code.value : code)
80+
return codes.includes(codeNumber)
81+
})
82+
if (diagnostics.length === 0) return
83+
}
84+
85+
return {
86+
title,
87+
diagnostics,
88+
kind: vscode.CodeActionKind.Empty.append(kind),
89+
requestRange: range,
90+
document,
91+
}
92+
}),
93+
),
94+
)
95+
96+
return codeActions
97+
},
98+
async resolveCodeAction(codeAction: ExtendedCodeAction, token) {
99+
const { document } = codeAction
100+
if (!document) throw new Error('Unresolved code action without document')
101+
const result = await sendCommand<RequestResponseTypes['getExtendedCodeActionEdits'], RequestOptionsTypes['getExtendedCodeActionEdits']>(
102+
'getExtendedCodeActionEdits',
103+
{
104+
document,
105+
inputOptions: {
106+
applyCodeActionTitle: codeAction.title,
107+
range: vscodeRangeToTs(document, codeAction.diagnostics?.length ? codeAction.diagnostics[0]!.range : codeAction.requestRange),
108+
},
109+
},
110+
)
111+
if (!result) throw new Error('No code action edits. Try debug.')
112+
const { edits = [], snippetEdits = [] } = result
113+
const workspaceEdit = new vscode.WorkspaceEdit()
114+
workspaceEdit.set(document.uri, [
115+
...tsTextChangesToVscodeTextEdits(document, edits),
116+
...tsTextChangesToVscodeSnippetTextEdits(document, snippetEdits),
117+
])
118+
codeAction.edit = workspaceEdit
119+
return codeAction
120+
},
121+
})
122+
123+
registerExtensionCommand('applyRefactor' as any, async (_, arg?: RequestResponseTypes['getTwoStepCodeActions']) => {
124+
if (!arg) return
125+
let sendNextData: RequestOptionsTypes['twoStepCodeActionSecondStep']['data'] | undefined
126+
const { turnArrayIntoObject, moveToExistingFile } = arg
127+
if (turnArrayIntoObject) {
128+
const { keysCount, totalCount, totalObjectCount } = turnArrayIntoObject
129+
const selectedKey = await showQuickPick(
130+
Object.entries(keysCount).map(([key, count]) => {
131+
const isAllowed = count === totalObjectCount
132+
return { label: `${isAllowed ? '$(check)' : '$(close)'}${key}`, value: isAllowed ? key : false, description: `${count} hits` }
133+
}),
134+
{
135+
title: `Selected available key from ${totalObjectCount} objects (${totalCount} elements)`,
136+
},
137+
)
138+
if (selectedKey === undefined || selectedKey === '') return
139+
if (selectedKey === false) {
140+
void vscode.window.showWarningMessage("Can't use selected key as its not used in object of every element")
141+
return
142+
}
143+
144+
sendNextData = {
145+
name: 'turnArrayIntoObject',
146+
selectedKeyName: selectedKey as string,
147+
}
148+
}
149+
150+
if (moveToExistingFile) {
151+
sendNextData = {
152+
name: 'moveToExistingFile',
153+
}
154+
}
155+
156+
if (!sendNextData) return
157+
const editor = vscode.window.activeTextEditor!
158+
const nextResponse = await getSecondStepRefactoringData(editor.selection, sendNextData)
159+
if (!nextResponse) throw new Error('No code action data. Try debug.')
160+
const edit = new vscode.WorkspaceEdit()
161+
let mainChanges = 'edits' in nextResponse && nextResponse.edits
162+
if (moveToExistingFile && 'fileNames' in nextResponse) {
163+
const { fileNames, fileEdits } = nextResponse
164+
const selectedFilePath = await pickFileWithQuickPick(fileNames)
165+
if (!selectedFilePath) return
166+
const document = await vscode.workspace.openTextDocument(vscode.Uri.file(selectedFilePath))
167+
// const outline = await vscode.commands.executeCommand('vscode.executeDocumentSymbolProvider', document.uri)
168+
169+
const currentEditorPath = getTsLikePath(vscode.window.activeTextEditor!.document.uri)
170+
const currentFileEdits = [...fileEdits.find(fileEdit => fileEdit.fileName === currentEditorPath)!.textChanges]
171+
const textChangeIndexToPatch = currentFileEdits.findIndex(currentFileEdit => currentFileEdit.newText.trim())
172+
const { newText: updateImportText } = currentFileEdits[textChangeIndexToPatch]!
173+
// TODO-mid use native path resolver (ext, index, alias)
174+
let newRelativePath = relative(join(currentEditorPath, '..'), selectedFilePath)
175+
if (!newRelativePath.startsWith('./') && !newRelativePath.startsWith('../')) newRelativePath = `./${newRelativePath}`
176+
currentFileEdits[textChangeIndexToPatch]!.newText = updateImportText.replace(/(['"]).+(['"])/, (_m, g1) => `${g1}${newRelativePath}${g1}`)
177+
mainChanges = currentFileEdits
178+
const newFileText = fileEdits.find(fileEdit => fileEdit.isNewFile)!.textChanges[0]!.newText
179+
const [importLines, otherLines] = partition(newFileText.split('\n'), line => line.startsWith('import '))
180+
const startPos = new vscode.Position(0, 0)
181+
const newFileNodes = await sendCommand<RequestResponseTypes['filterBySyntaxKind']>('filterBySyntaxKind', {
182+
position: startPos,
183+
document,
184+
})
185+
const lastImportDeclaration = newFileNodes?.nodesByKind.ImportDeclaration?.at(-1)
186+
const lastImportEnd = lastImportDeclaration ? tsRangeToVscode(document, lastImportDeclaration.range).end : startPos
187+
edit.set(vscode.Uri.file(selectedFilePath), [
188+
{
189+
range: new vscode.Range(startPos, startPos),
190+
newText: [...importLines, '\n'].join('\n'),
191+
},
192+
{
193+
range: new vscode.Range(lastImportEnd, lastImportEnd),
194+
newText: ['\n', ...otherLines].join('\n'),
195+
},
196+
])
197+
}
198+
199+
if (!mainChanges) return
200+
edit.set(editor.document.uri, tsTextChangesToVscodeTextEdits(editor.document, mainChanges))
201+
await vscode.workspace.applyEdit(edit)
202+
})
203+
204+
async function getPossibleTwoStepRefactorings(range: vscode.Range, document = vscode.window.activeTextEditor!.document) {
205+
return sendCommand<RequestResponseTypes['getTwoStepCodeActions'], RequestOptionsTypes['getTwoStepCodeActions']>('getTwoStepCodeActions', {
206+
document,
207+
position: range.start,
208+
inputOptions: {
209+
range: vscodeRangeToTs(document, range),
210+
},
211+
})
212+
}
213+
214+
async function getSecondStepRefactoringData(range: vscode.Range, secondStepData?: any, document = vscode.window.activeTextEditor!.document) {
215+
return sendCommand<RequestResponseTypes['twoStepCodeActionSecondStep'], RequestOptionsTypes['twoStepCodeActionSecondStep']>(
216+
'twoStepCodeActionSecondStep',
217+
{
218+
document,
219+
position: range.start,
220+
inputOptions: {
221+
range: vscodeRangeToTs(document, range),
222+
data: secondStepData,
223+
},
224+
},
225+
)
226+
}
227+
}

src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import specialCommands from './specialCommands'
1515
import vueVolarSupport from './vueVolarSupport'
1616
import moreCompletions from './moreCompletions'
1717
import { mergeSettingsFromScopes } from './mergeSettings'
18+
import codeActionProvider from './codeActionProvider'
1819

1920
let isActivated = false
2021
// let erroredStatusBarItem: vscode.StatusBarItem | undefined
@@ -82,6 +83,7 @@ export const activateTsPlugin = (tsApi: { configurePlugin; onCompletionAccepted
8283
webImports()
8384
apiCommands()
8485
specialCommands()
86+
codeActionProvider()
8587

8688
figIntegration()
8789
vueVolarSupport()

0 commit comments

Comments
 (0)