Skip to content

Commit fc3e6d7

Browse files
authored
feat: add blame status bar item (#175)
1 parent 412f354 commit fc3e6d7

File tree

3 files changed

+200
-57
lines changed

3 files changed

+200
-57
lines changed

src/blame.test.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
getAllBlameDecorations,
44
getBlameDecorations,
55
getBlameDecorationsForSelections,
6+
getBlameStatusBarItem,
67
getDecorationFromHunk,
78
Hunk,
89
} from './blame'
@@ -229,14 +230,13 @@ describe('getAllBlameDecorations()', () => {
229230
describe('getBlameDecorations()', () => {
230231
it('gets decorations for all hunks if no selections are passed', async () => {
231232
expect(
232-
await getBlameDecorations({
233-
uri: 'a',
233+
getBlameDecorations({
234234
settings: {
235235
'git.blame.decorations': 'line',
236236
},
237237
now: NOW,
238238
selections: null,
239-
queryHunks: () => Promise.resolve([FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4]),
239+
hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4],
240240
sourcegraph: SOURCEGRAPH as any,
241241
})
242242
).toEqual([
@@ -249,48 +249,45 @@ describe('getBlameDecorations()', () => {
249249

250250
it('gets decorations for the selections if selections are passed', async () => {
251251
expect(
252-
await getBlameDecorations({
253-
uri: 'a',
252+
getBlameDecorations({
254253
settings: {
255254
'git.blame.decorations': 'line',
256255
},
257256
now: NOW,
258257
selections: [
259258
new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(3, 0), new SOURCEGRAPH.Position(3, 0)) as any,
260259
],
261-
queryHunks: () => Promise.resolve([FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4]),
260+
hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4],
262261
sourcegraph: SOURCEGRAPH as any,
263262
})
264263
).toEqual([getDecorationFromHunk(FIXTURE_HUNK_4, NOW, 3, SOURCEGRAPH as any)])
265264
})
266265

267266
it('gets no decorations if git.blame.decorations is "none"', async () => {
268267
expect(
269-
await getBlameDecorations({
270-
uri: 'a',
268+
getBlameDecorations({
271269
settings: {
272270
'git.blame.decorations': 'none',
273271
},
274272
now: NOW,
275273
selections: null,
276-
queryHunks: () => Promise.resolve([FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4]),
274+
hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4],
277275
sourcegraph: SOURCEGRAPH as any,
278276
})
279277
).toEqual([])
280278
})
281279

282280
it('gets decorations for all hunks if git.blame.decorations is "file"', async () => {
283281
expect(
284-
await getBlameDecorations({
285-
uri: 'a',
282+
getBlameDecorations({
286283
settings: {
287284
'git.blame.decorations': 'file',
288285
},
289286
now: NOW,
290287
selections: [
291288
new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(3, 0), new SOURCEGRAPH.Position(3, 0)) as any,
292289
],
293-
queryHunks: () => Promise.resolve([FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4]),
290+
hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4],
294291
sourcegraph: SOURCEGRAPH as any,
295292
})
296293
).toEqual([
@@ -314,3 +311,38 @@ describe('getBlameDecorations()', () => {
314311
).toBe(true)
315312
})
316313
})
314+
315+
describe('getBlameStatusBarItem()', () => {
316+
it('displays the hunk for the first selected line', () => {
317+
expect(
318+
getBlameStatusBarItem({
319+
selections: [
320+
new SOURCEGRAPH.Selection(new SOURCEGRAPH.Position(3, 0), new SOURCEGRAPH.Position(3, 0)) as any,
321+
],
322+
hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4],
323+
sourcegraph: SOURCEGRAPH as any,
324+
now: NOW,
325+
}).text
326+
).toBe('Blame: (testUserName) i, 2 months ago')
327+
})
328+
329+
it('displays the most recent hunk if there are no selections', () => {
330+
expect(
331+
getBlameStatusBarItem({
332+
selections: [],
333+
hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4],
334+
sourcegraph: SOURCEGRAPH as any,
335+
now: NOW,
336+
}).text
337+
).toBe('Blame: e, 21 days ago')
338+
339+
expect(
340+
getBlameStatusBarItem({
341+
selections: null,
342+
hunks: [FIXTURE_HUNK_1, FIXTURE_HUNK_2, FIXTURE_HUNK_3, FIXTURE_HUNK_4],
343+
sourcegraph: SOURCEGRAPH as any,
344+
now: NOW,
345+
}).text
346+
).toBe('Blame: e, 21 days ago')
347+
})
348+
})

src/blame.ts

Lines changed: 137 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,49 @@
1+
import compareDesc from 'date-fns/compareDesc'
12
import formatDistanceStrict from 'date-fns/formatDistanceStrict'
2-
import { Selection, TextDocumentDecoration } from 'sourcegraph'
3+
import { Selection, StatusBarItem, TextDocumentDecoration } from 'sourcegraph'
34
import gql from 'tagged-template-noop'
45
import { Settings } from './extension'
56
import { resolveURI } from './uri'
67
import { memoizeAsync } from './util/memoizeAsync'
78

8-
export const getDecorationFromHunk = (
9-
{ message, author, commit }: Hunk,
9+
/**
10+
* Get display info shared between status bar items and text document decorations.
11+
*/
12+
const getDisplayInfoFromHunk = (
13+
{ author, commit, message }: Pick<Hunk, 'author' | 'commit' | 'message'>,
1014
now: number,
11-
decoratedLine: number,
1215
sourcegraph: typeof import('sourcegraph')
13-
): TextDocumentDecoration => {
16+
): { displayName: string; username: string; distance: string; linkURL: string; hoverMessage: string } => {
1417
const displayName = truncate(author.person.displayName, 25)
1518
const username = author.person.user ? `(${author.person.user.username}) ` : ''
1619
const distance = formatDistanceStrict(author.date, now, { addSuffix: true })
20+
const linkURL = new URL(commit.url, sourcegraph.internal.sourcegraphURL.toString()).href
21+
const hoverMessage = `${author.person.email}${truncate(message, 1000)}`
22+
1723
return {
18-
range: new sourcegraph.Range(decoratedLine, 0, decoratedLine, 0),
19-
isWholeLine: true,
20-
after: {
21-
light: {
22-
color: 'rgba(0, 0, 25, 0.55)',
23-
backgroundColor: 'rgba(193, 217, 255, 0.65)',
24-
},
25-
dark: {
26-
color: 'rgba(235, 235, 255, 0.55)',
27-
backgroundColor: 'rgba(15, 43, 89, 0.65)',
28-
},
29-
contentText: `${username}${displayName}, ${distance}: • ${truncate(message, 45)}`,
30-
hoverMessage: `${author.person.email}${truncate(message, 1000)}`,
31-
linkURL: new URL(commit.url, sourcegraph.internal.sourcegraphURL.toString()).href,
32-
},
24+
displayName,
25+
username,
26+
distance,
27+
linkURL,
28+
hoverMessage,
3329
}
3430
}
3531

36-
export const getBlameDecorationsForSelections = (
32+
/**
33+
* Get hunks and 0-indexed start lines for the given selections.
34+
*
35+
* @param selections If null, returns all hunks
36+
*/
37+
export const getHunksForSelections = (
3738
hunks: Hunk[],
38-
selections: Selection[],
39-
now: number,
40-
sourcegraph: typeof import('sourcegraph')
41-
) => {
42-
const decorations: TextDocumentDecoration[] = []
39+
selections: Selection[] | null
40+
): { selectionStartLine: number; hunk: Hunk }[] => {
41+
const hunksForSelections: { selectionStartLine: number; hunk: Hunk }[] = []
42+
43+
if (!selections) {
44+
return hunks.map(hunk => ({ hunk, selectionStartLine: hunk.startLine - 1 }))
45+
}
46+
4347
for (const hunk of hunks) {
4448
// Hunk start and end lines are 1-indexed, but selection lines are zero-indexed
4549
const hunkStartLineZeroBased = hunk.startLine - 1
@@ -50,20 +54,59 @@ export const getBlameDecorationsForSelections = (
5054
if (selection.end.line < hunkStartLineZeroBased || selection.start.line > hunkEndLineZeroBased) {
5155
continue
5256
}
57+
5358
// Decorate the hunk's start line or, if the hunk's start line is
5459
// outside of the selection's boundaries, the start line of the selection.
55-
const decoratedLine =
60+
const selectionStartLine =
5661
hunkStartLineZeroBased < selection.start.line ? selection.start.line : hunkStartLineZeroBased
57-
decorations.push(getDecorationFromHunk(hunk, now, decoratedLine, sourcegraph))
62+
hunksForSelections.push({ selectionStartLine, hunk })
5863
}
5964
}
60-
return decorations
65+
66+
return hunksForSelections
67+
}
68+
69+
export const getDecorationFromHunk = (
70+
hunk: Hunk,
71+
now: number,
72+
decoratedLine: number,
73+
sourcegraph: typeof import('sourcegraph')
74+
): TextDocumentDecoration => {
75+
const { displayName, username, distance, linkURL, hoverMessage } = getDisplayInfoFromHunk(hunk, now, sourcegraph)
76+
77+
return {
78+
range: new sourcegraph.Range(decoratedLine, 0, decoratedLine, 0),
79+
isWholeLine: true,
80+
after: {
81+
light: {
82+
color: 'rgba(0, 0, 25, 0.55)',
83+
backgroundColor: 'rgba(193, 217, 255, 0.65)',
84+
},
85+
dark: {
86+
color: 'rgba(235, 235, 255, 0.55)',
87+
backgroundColor: 'rgba(15, 43, 89, 0.65)',
88+
},
89+
contentText: `${username}${displayName}, ${distance}: • ${truncate(hunk.message, 45)}`,
90+
hoverMessage,
91+
linkURL,
92+
},
93+
}
6194
}
6295

96+
export const getBlameDecorationsForSelections = (
97+
hunks: Hunk[],
98+
selections: Selection[],
99+
now: number,
100+
sourcegraph: typeof import('sourcegraph')
101+
) =>
102+
getHunksForSelections(hunks, selections).map(({ hunk, selectionStartLine }) =>
103+
getDecorationFromHunk(hunk, now, selectionStartLine, sourcegraph)
104+
)
105+
63106
export const getAllBlameDecorations = (hunks: Hunk[], now: number, sourcegraph: typeof import('sourcegraph')) =>
64107
hunks.map(hunk => getDecorationFromHunk(hunk, now, hunk.startLine - 1, sourcegraph))
65108

66-
const queryBlameHunks = memoizeAsync(
109+
export const queryBlameHunks = memoizeAsync(
67110
async ({ uri, sourcegraph }: { uri: string; sourcegraph: typeof import('sourcegraph') }): Promise<Hunk[]> => {
68111
const { repo, rev, path } = resolveURI(uri)
69112
const { data, errors } = await sourcegraph.commands.executeCommand(
@@ -111,39 +154,93 @@ const queryBlameHunks = memoizeAsync(
111154
)
112155

113156
/**
114-
* Queries the blame hunks for the document at the provided URI,
115-
* and returns blame decorations for all provided selections,
157+
* Returns blame decorations for all provided selections,
116158
* or for all hunks if `selections` is `null`.
117-
*
118159
*/
119-
export const getBlameDecorations = async ({
120-
uri,
160+
export const getBlameDecorations = ({
121161
settings,
122162
selections,
123163
now,
124-
queryHunks = queryBlameHunks,
164+
hunks,
125165
sourcegraph,
126166
}: {
127-
uri: string
128167
settings: Settings
129168
selections: Selection[] | null
130169
now: number
131-
queryHunks?: ({ uri, sourcegraph }: { uri: string; sourcegraph: typeof import('sourcegraph') }) => Promise<Hunk[]>
170+
hunks: Hunk[]
132171
sourcegraph: typeof import('sourcegraph')
133-
}): Promise<TextDocumentDecoration[]> => {
172+
}): TextDocumentDecoration[] => {
134173
const decorations = settings['git.blame.decorations'] || 'none'
135174

136175
if (decorations === 'none') {
137176
return []
138177
}
139-
const hunks = await queryHunks({ uri, sourcegraph })
140178
if (selections !== null && decorations === 'line') {
141179
return getBlameDecorationsForSelections(hunks, selections, now, sourcegraph)
142180
} else {
143181
return getAllBlameDecorations(hunks, now, sourcegraph)
144182
}
145183
}
146184

185+
export const getBlameStatusBarItem = ({
186+
selections,
187+
hunks,
188+
now,
189+
sourcegraph,
190+
}: {
191+
selections: Selection[] | null
192+
hunks: Hunk[]
193+
now: number
194+
sourcegraph: typeof import('sourcegraph')
195+
}): StatusBarItem => {
196+
if (selections && selections.length > 0) {
197+
const hunksForSelections = getHunksForSelections(hunks, selections)
198+
if (hunksForSelections[0]) {
199+
// Display the commit for the first selected hunk in the status bar.
200+
const { displayName, username, distance, linkURL, hoverMessage } = getDisplayInfoFromHunk(
201+
hunksForSelections[0].hunk,
202+
now,
203+
sourcegraph
204+
)
205+
206+
return {
207+
text: `Blame: ${username}${displayName}, ${distance}`,
208+
command: { id: 'open', args: [linkURL] },
209+
tooltip: hoverMessage,
210+
}
211+
}
212+
}
213+
214+
// Since there are no selections, we want to determine the most
215+
// recent change to this file to display in the status bar.
216+
217+
// Get all hunks
218+
const hunksForSelections = getHunksForSelections(hunks, null)
219+
const mostRecentHunk = hunksForSelections.sort((a, b) => compareDesc(a.hunk.author.date, b.hunk.author.date))[0]
220+
if (!mostRecentHunk) {
221+
// Probably a network error
222+
return {
223+
text: 'Blame: not found',
224+
}
225+
}
226+
const { displayName, username, distance, linkURL, hoverMessage } = getDisplayInfoFromHunk(
227+
mostRecentHunk.hunk,
228+
now,
229+
sourcegraph
230+
)
231+
232+
return {
233+
text: `Blame: ${username}${displayName}, ${distance}`,
234+
command: { id: 'open', args: [linkURL] },
235+
tooltip: hoverMessage,
236+
}
237+
}
238+
239+
export interface HunkForSelection {
240+
hunk: Hunk
241+
selectionStartLine: number
242+
}
243+
147244
export interface Hunk {
148245
startLine: number
149246
endLine: number

0 commit comments

Comments
 (0)