Skip to content

Commit 3577dce

Browse files
author
Marek
authored
Merge pull request #3 from sourcegraph/develop-find-replace
Create find-replace extension
2 parents 5587ea3 + d28f44e commit 3577dce

File tree

7 files changed

+222
-21
lines changed

7 files changed

+222
-21
lines changed

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1-
# find-replace (Sourcegraph extension)
1+
# Find-replace campaigns (Sourcegraph extension)
22

3-
Perform a find-replace over multiple repositories by creating a Sourcegraph campaign.
3+
Create Sourcegraph campaigns that perform a find-replace on multiple files in multiple repositories.
4+
5+
## What are Sourcegraph campaigns?
6+
7+
Campaigns let you make code changes across many repositories and code hosts. Read more about [campaigns in the Sourcegraph docs.](https://docs.sourcegraph.com/campaigns)
8+
9+
## How does a find-replace campaign work?
10+
11+
A find-replace campaign is a campaign that finds all matches of a given string and replaces them with a replacement string.
12+
13+
- Perform a search on Sourcegraph
14+
- In the search results toolbar, click "Find-replace"
15+
- Enter the string to find
16+
- Enter the replacement string
17+
- Once the process is complete, click the notification to open the newly created campaign.

package.json

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,34 @@
88
],
99
"wip": true,
1010
"categories": [],
11-
"tags": [],
11+
"tags": [
12+
"campaigns"
13+
],
1214
"contributes": {
13-
"actions": [],
15+
"actions": [
16+
{
17+
"id": "findReplace.startFindReplace",
18+
"title": "Create a new find-replace campaign",
19+
"command": "findReplace.startFindReplace",
20+
"commandArguments": [
21+
"${get(context, 'searchQuery')}"
22+
],
23+
"actionItem": {
24+
"label": "Find-replace"
25+
}
26+
}
27+
],
1428
"menus": {
15-
"editor/title": [],
16-
"commandPalette": []
29+
"search/results/toolbar": [
30+
{
31+
"action": "findReplace.startFindReplace"
32+
}
33+
],
34+
"commandPalette": [
35+
{
36+
"action": "findReplace.startFindReplace"
37+
}
38+
]
1739
},
1840
"configuration": {}
1941
},
@@ -28,7 +50,9 @@
2850
"serve": "yarn run symlink-package && parcel serve --no-hmr --out-file dist/find-replace.js src/find-replace.ts",
2951
"watch:typecheck": "tsc -p tsconfig.json -w",
3052
"watch:build": "tsc -p tsconfig.dist.json -w",
31-
"sourcegraph:prepublish": "yarn run typecheck && yarn run build"
53+
"sourcegraph:prepublish": "yarn run typecheck && yarn run build",
54+
"prettier": "prettier '**/*.{ts,md,json}' --write --list-different",
55+
"test": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' mocha --require ts-node/register **/*.test.ts"
3256
},
3357
"browserslist": [
3458
"last 1 Chrome versions",
@@ -39,11 +63,22 @@
3963
"devDependencies": {
4064
"@sourcegraph/eslint-config": "^0.20.11",
4165
"@sourcegraph/tsconfig": "^4.0.1",
66+
"@types/assert": "^1.5.2",
67+
"@types/mocha": "^8.0.3",
68+
"assert": "^2.0.0",
4269
"eslint": "^7.11.0",
4370
"lnfs-cli": "^2.1.0",
4471
"mkdirp": "^1.0.4",
72+
"mocha": "^8.2.0",
4573
"parcel-bundler": "^1.12.4",
74+
"prettier": "^2.1.2",
4675
"sourcegraph": "^24.7.0",
4776
"typescript": "^4.0.3"
77+
},
78+
"dependencies": {
79+
"@sourcegraph/campaigns-client": "file:../campaigns-client",
80+
"rxjs": "^6.6.3",
81+
"slugify": "^1.4.5",
82+
"ts-node": "^9.0.0"
4883
}
4984
}

src/edit-file.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { editFile } from './edit-file'
2+
import assert from 'assert'
3+
4+
const input = 'Red redder reddest'
5+
6+
describe('editFile', () => {
7+
it('returns identical output', () => {
8+
const output = editFile(input, 'foo', 'bar')
9+
assert.strictEqual(output, input)
10+
})
11+
12+
it('replaces a string with a replacement', () => {
13+
const output = editFile(input, 'red', 'bar')
14+
assert.strictEqual(output, 'Red barder bardest')
15+
})
16+
17+
it('replaces a string with a blank string', () => {
18+
const output = editFile(input, 'ed', '')
19+
assert.strictEqual(output, 'R rder rdest')
20+
})
21+
22+
it('works with blank find string', () => {
23+
const output = editFile(input, '', 'foo')
24+
assert.strictEqual(output, input)
25+
})
26+
})

src/edit-file.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Edit a file to apply a string replacement
3+
*/
4+
export function editFile(text: string, findString: string, replacement: string): string {
5+
if (findString.length === 0) {
6+
return text
7+
}
8+
9+
if (!text.includes(findString)) {
10+
return text
11+
}
12+
13+
return text.split(findString).join(replacement)
14+
}

src/find-replace.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,6 @@
1-
import * as sourcegraph from 'sourcegraph'
1+
import sourcegraph from 'sourcegraph'
2+
import { registerFindReplaceAction } from './register-action'
23

3-
export function activate(ctx: sourcegraph.ExtensionContext): void {
4-
ctx.subscriptions.add(
5-
sourcegraph.languages.registerHoverProvider(['*'], {
6-
provideHover: () => ({
7-
contents: {
8-
value: 'Hello world from find-replace! 🎉🎉🎉',
9-
kind: sourcegraph.MarkupKind.Markdown
10-
}
11-
}),
12-
})
13-
)
4+
export function activate(context: sourcegraph.ExtensionContext): void {
5+
context.subscriptions.add(registerFindReplaceAction())
146
}
15-
16-
// Sourcegraph extension documentation: https://docs.sourcegraph.com/extensions/authoring

src/register-action.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Subscription } from 'rxjs'
2+
import sourcegraph from 'sourcegraph'
3+
import { evaluateAndCreateCampaignSpec } from '@sourcegraph/campaigns-client'
4+
import slugify from 'slugify'
5+
import { getCurrentUser } from './util'
6+
import { editFile } from './edit-file'
7+
8+
// TODO: sanitize this for real, it gets used in the description of the campaign
9+
const escapedMarkdownCode = (text: string): string => '`' + text.replace(/`/g, '\\`') + '`'
10+
11+
export const registerFindReplaceAction = (): Subscription => {
12+
const subscription = new Subscription()
13+
subscription.add(
14+
sourcegraph.commands.registerCommand('findReplace.startFindReplace', async (searchQuery: string) => {
15+
// TODO: in the future, use the search query to get the list of matching files.
16+
console.log('context.searchQuery', searchQuery)
17+
18+
if (!searchQuery) {
19+
return
20+
}
21+
22+
// To create campaigns, a namespace is used, which can be the current user's username.
23+
const currentUser = await getCurrentUser()
24+
if (!currentUser) {
25+
throw new Error('No current user')
26+
}
27+
const namespaceName = currentUser.username
28+
29+
const findString = await sourcegraph.app.activeWindow!.showInputBox({
30+
prompt: 'Find all matches of:',
31+
})
32+
if (!findString) {
33+
return
34+
}
35+
36+
const replacementString = await sourcegraph.app.activeWindow!.showInputBox({
37+
prompt: 'Replace with:',
38+
})
39+
// Empty string is a valid replacement, so compare directly with undefined.
40+
if (replacementString === undefined) {
41+
return
42+
}
43+
44+
const campaignName = `replace-${slugify(findString)}-with-${slugify(replacementString)}`
45+
const description = `Replace ${escapedMarkdownCode(findString)} with ${escapedMarkdownCode(
46+
replacementString
47+
)}`
48+
49+
let percentage = 0
50+
const { applyURL, diffStat } = await sourcegraph.app.activeWindow!.withProgress(
51+
{ title: '**Find-replace**' },
52+
reporter =>
53+
evaluateAndCreateCampaignSpec(namespaceName, {
54+
name: campaignName,
55+
on: [
56+
{
57+
repositoriesMatchingQuery: searchQuery,
58+
},
59+
],
60+
description,
61+
steps: [
62+
{
63+
// fileFilter is a required property, but we want it to be a noop.
64+
fileFilter: () => true,
65+
editFile: (path, text) => {
66+
if (!text.includes(findString)) {
67+
// skip the file by returning null
68+
return null
69+
}
70+
71+
percentage += (100 - percentage) / 100
72+
reporter.next({ message: `Computing changes in ${path}`, percentage })
73+
74+
return editFile(text, findString, replacementString)
75+
},
76+
},
77+
],
78+
changesetTemplate: {
79+
title: description,
80+
branch: `campaign/${campaignName}`,
81+
commit: {
82+
message: description,
83+
author: {
84+
name: currentUser.username,
85+
email: currentUser.email,
86+
},
87+
},
88+
published: false,
89+
},
90+
})
91+
)
92+
sourcegraph.app.activeWindow!.showNotification(
93+
`[**Find-replace changes**](${applyURL}) are ready to preview and apply.\n\n<small>${diffStat.changed} changes made.</small>`,
94+
sourcegraph.NotificationType.Success
95+
)
96+
})
97+
)
98+
return subscription
99+
}

src/util.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import sourcegraph from 'sourcegraph'
2+
3+
interface CurrentUserResponse {
4+
data?: {
5+
currentUser?: {
6+
username: string
7+
email: string
8+
}
9+
}
10+
errors?: string[]
11+
}
12+
13+
export async function getCurrentUser(): Promise<{ username: string; email: string } | undefined> {
14+
const response = await sourcegraph.commands.executeCommand<CurrentUserResponse>(
15+
'queryGraphQL',
16+
`{
17+
currentUser {
18+
username, email
19+
}
20+
}`
21+
)
22+
return response?.data?.currentUser
23+
}

0 commit comments

Comments
 (0)