@theia/scm 1.71.0-next.72 → 1.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/browser/dirty-diff/dirty-diff-widget.js +1 -1
- package/lib/browser/dirty-diff/dirty-diff-widget.js.map +1 -1
- package/lib/browser/scm-context-key-service.d.ts +6 -0
- package/lib/browser/scm-context-key-service.d.ts.map +1 -1
- package/lib/browser/scm-context-key-service.js +12 -0
- package/lib/browser/scm-context-key-service.js.map +1 -1
- package/lib/browser/scm-contribution.d.ts.map +1 -1
- package/lib/browser/scm-contribution.js +129 -1
- package/lib/browser/scm-contribution.js.map +1 -1
- package/lib/browser/scm-frontend-module.d.ts.map +1 -1
- package/lib/browser/scm-frontend-module.js +14 -0
- package/lib/browser/scm-frontend-module.js.map +1 -1
- package/lib/browser/scm-history-graph-helpers.d.ts +39 -0
- package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-helpers.js +167 -0
- package/lib/browser/scm-history-graph-helpers.js.map +1 -0
- package/lib/browser/scm-history-graph-lanes.d.ts +59 -0
- package/lib/browser/scm-history-graph-lanes.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-lanes.js +183 -0
- package/lib/browser/scm-history-graph-lanes.js.map +1 -0
- package/lib/browser/scm-history-graph-lanes.spec.d.ts +2 -0
- package/lib/browser/scm-history-graph-lanes.spec.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-lanes.spec.js +554 -0
- package/lib/browser/scm-history-graph-lanes.spec.js.map +1 -0
- package/lib/browser/scm-history-graph-model.d.ts +46 -0
- package/lib/browser/scm-history-graph-model.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-model.js +184 -0
- package/lib/browser/scm-history-graph-model.js.map +1 -0
- package/lib/browser/scm-history-graph-model.spec.d.ts +2 -0
- package/lib/browser/scm-history-graph-model.spec.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-model.spec.js +131 -0
- package/lib/browser/scm-history-graph-model.spec.js.map +1 -0
- package/lib/browser/scm-history-graph-tooltip.d.ts +14 -0
- package/lib/browser/scm-history-graph-tooltip.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-tooltip.js +190 -0
- package/lib/browser/scm-history-graph-tooltip.js.map +1 -0
- package/lib/browser/scm-history-graph-widget.d.ts +77 -0
- package/lib/browser/scm-history-graph-widget.d.ts.map +1 -0
- package/lib/browser/scm-history-graph-widget.js +490 -0
- package/lib/browser/scm-history-graph-widget.js.map +1 -0
- package/lib/browser/scm-provider.d.ts +61 -0
- package/lib/browser/scm-provider.d.ts.map +1 -1
- package/lib/browser/scm-provider.js.map +1 -1
- package/package.json +7 -7
- package/src/browser/dirty-diff/dirty-diff-widget.ts +1 -1
- package/src/browser/scm-context-key-service.ts +18 -0
- package/src/browser/scm-contribution.ts +141 -0
- package/src/browser/scm-frontend-module.ts +15 -0
- package/src/browser/scm-history-graph-helpers.ts +175 -0
- package/src/browser/scm-history-graph-lanes.spec.ts +635 -0
- package/src/browser/scm-history-graph-lanes.ts +258 -0
- package/src/browser/scm-history-graph-model.spec.ts +171 -0
- package/src/browser/scm-history-graph-model.ts +207 -0
- package/src/browser/scm-history-graph-tooltip.ts +213 -0
- package/src/browser/scm-history-graph-widget.tsx +712 -0
- package/src/browser/scm-provider.ts +68 -0
- package/src/browser/style/index.css +12 -13
- package/src/browser/style/scm-history-graph.css +313 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource GmbH and others.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import * as React from '@theia/core/shared/react';
|
|
18
|
+
import { Virtuoso } from '@theia/core/shared/react-virtuoso';
|
|
19
|
+
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
|
|
20
|
+
import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget';
|
|
21
|
+
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
|
|
22
|
+
import { HoverService } from '@theia/core/lib/browser/hover-service';
|
|
23
|
+
import { MarkdownRenderer, MarkdownRendererFactory } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';
|
|
24
|
+
import { ScmHistoryGraphModel, HistoryGraphEntry } from './scm-history-graph-model';
|
|
25
|
+
import { ScmHistoryItemRef, ScmHistoryItemChange } from './scm-provider';
|
|
26
|
+
import { ScmService } from './scm-service';
|
|
27
|
+
import { GraphRow } from './scm-history-graph-lanes';
|
|
28
|
+
import URI from '@theia/core/lib/common/uri';
|
|
29
|
+
import { nls } from '@theia/core/lib/common/nls';
|
|
30
|
+
import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
|
|
31
|
+
import { MenuPath } from '@theia/core/lib/common/menu/menu-types';
|
|
32
|
+
import { ContextMenuRenderer } from '@theia/core/lib/browser/context-menu-renderer';
|
|
33
|
+
import { OpenerService, open } from '@theia/core/lib/browser/opener-service';
|
|
34
|
+
import { DiffUris } from '@theia/core/lib/browser/diff-uris';
|
|
35
|
+
import { ScmContextKeyService } from './scm-context-key-service';
|
|
36
|
+
import {
|
|
37
|
+
laneColor, getChangeStatus, getFileName, getFilePath, getRepoRelativePath,
|
|
38
|
+
getRefBadgeClass, isTagRef, isRemoteRef, deduplicateRefs, DeduplicatedRef
|
|
39
|
+
} from './scm-history-graph-helpers';
|
|
40
|
+
import { buildHtmlTooltip } from './scm-history-graph-tooltip';
|
|
41
|
+
|
|
42
|
+
/** Menu path matching the VS Code 'scm/history/title' contribution point (graph section toolbar). */
|
|
43
|
+
export const SCM_HISTORY_TITLE_MENU: MenuPath = ['plugin_scm/history/title'];
|
|
44
|
+
/** Menu path matching the VS Code 'scm/historyItem/context' contribution point (commit row context menu). */
|
|
45
|
+
export const SCM_HISTORY_ITEM_CONTEXT_MENU: MenuPath = ['plugin_scm/historyItem/context'];
|
|
46
|
+
/** Menu path matching the VS Code 'scm/historyItemRef/context' contribution point (ref badge context menu). */
|
|
47
|
+
export const SCM_HISTORY_ITEM_REF_CONTEXT_MENU: MenuPath = ['plugin_scm/historyItemRef/context'];
|
|
48
|
+
|
|
49
|
+
// ── Layout constants ────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const ROW_HEIGHT = 22;
|
|
52
|
+
/** Horizontal width of each lane column in the SVG — matches VS Code exactly. */
|
|
53
|
+
const LANE_WIDTH = 22;
|
|
54
|
+
/** Y position of the commit dot — vertically centered in the row. */
|
|
55
|
+
const DOT_CY = 11;
|
|
56
|
+
|
|
57
|
+
/** Renders a ref badge as a JSX element for the commit row. */
|
|
58
|
+
function renderJsxRefBadge(
|
|
59
|
+
ref: ScmHistoryItemRef,
|
|
60
|
+
iconClass: string,
|
|
61
|
+
showText: boolean,
|
|
62
|
+
style: React.CSSProperties,
|
|
63
|
+
key: string,
|
|
64
|
+
onContextMenu?: (e: React.MouseEvent) => void
|
|
65
|
+
): React.ReactElement {
|
|
66
|
+
return (
|
|
67
|
+
<span
|
|
68
|
+
key={key}
|
|
69
|
+
className={`scm-history-ref-badge ${getRefBadgeClass(ref)}`}
|
|
70
|
+
title={ref.description ?? ref.name}
|
|
71
|
+
style={style}
|
|
72
|
+
onContextMenu={onContextMenu}
|
|
73
|
+
>
|
|
74
|
+
<i className={`codicon ${iconClass} scm-history-ref-icon`} />
|
|
75
|
+
{showText && <span className='scm-history-ref-text'>{ref.name}</span>}
|
|
76
|
+
</span>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Widget ──────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
@injectable()
|
|
83
|
+
export class ScmHistoryGraphWidget extends ReactWidget {
|
|
84
|
+
|
|
85
|
+
static readonly ID = 'scm-history-graph-widget';
|
|
86
|
+
static readonly LABEL = nls.localizeByDefault('Graph');
|
|
87
|
+
|
|
88
|
+
@inject(ScmHistoryGraphModel) protected readonly model: ScmHistoryGraphModel;
|
|
89
|
+
@inject(HoverService) protected readonly hoverService: HoverService;
|
|
90
|
+
@inject(MarkdownRendererFactory) protected readonly markdownRendererFactory: MarkdownRendererFactory;
|
|
91
|
+
@inject(ScmService) protected readonly scmService: ScmService;
|
|
92
|
+
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
|
|
93
|
+
@inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer;
|
|
94
|
+
@inject(OpenerService) protected readonly openerService: OpenerService;
|
|
95
|
+
@inject(ScmContextKeyService) protected readonly scmContextKeys: ScmContextKeyService;
|
|
96
|
+
|
|
97
|
+
protected selectedIndex = -1;
|
|
98
|
+
/** Currently selected change row key (`${itemId}-${ci}`), or undefined. */
|
|
99
|
+
protected selectedChangeKey: string | undefined;
|
|
100
|
+
/** Map from commit id → loaded changes (undefined = not loaded yet). */
|
|
101
|
+
protected expandedChanges = new Map<string, ScmHistoryItemChange[] | 'loading'>();
|
|
102
|
+
/** Set of commit ids that are currently expanded. */
|
|
103
|
+
protected expandedIds = new Set<string>();
|
|
104
|
+
/** Map from commit id → in-flight CancellationTokenSource for loadChanges. */
|
|
105
|
+
protected loadChangesCts = new Map<string, CancellationTokenSource>();
|
|
106
|
+
|
|
107
|
+
constructor() {
|
|
108
|
+
super();
|
|
109
|
+
this.id = ScmHistoryGraphWidget.ID;
|
|
110
|
+
this.title.label = ScmHistoryGraphWidget.LABEL;
|
|
111
|
+
this.title.caption = ScmHistoryGraphWidget.LABEL;
|
|
112
|
+
this.title.closable = false;
|
|
113
|
+
this.addClass('scm-history-graph-container');
|
|
114
|
+
this.node.tabIndex = 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@postConstruct()
|
|
118
|
+
protected init(): void {
|
|
119
|
+
this.toDispose.push(
|
|
120
|
+
this.model.onDidChange(() => {
|
|
121
|
+
this.updateContextKeys();
|
|
122
|
+
this.update();
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
this.toDispose.push({
|
|
126
|
+
dispose: () => {
|
|
127
|
+
for (const cts of this.loadChangesCts.values()) {
|
|
128
|
+
cts.cancel();
|
|
129
|
+
cts.dispose();
|
|
130
|
+
}
|
|
131
|
+
this.loadChangesCts.clear();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
this.update();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
protected updateContextKeys(): void {
|
|
138
|
+
const provider = this.model.provider;
|
|
139
|
+
this.scmContextKeys.scmCurrentHistoryItemRefHasRemote.set(!!provider?.currentHistoryItemRemoteRef);
|
|
140
|
+
this.scmContextKeys.scmCurrentHistoryItemRefHasBase.set(!!provider?.currentHistoryItemBaseRef);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
protected render(): React.ReactNode {
|
|
144
|
+
const { entries, hasMore, loading } = this.model;
|
|
145
|
+
|
|
146
|
+
// Only show the empty state once the model has completed at least one load attempt.
|
|
147
|
+
// While the model is still initializing (provider not yet set, no load attempted),
|
|
148
|
+
// render a loading indicator to avoid a flash of "no history" on startup.
|
|
149
|
+
if (!loading && entries.length === 0) {
|
|
150
|
+
if (!this.model.hasAttemptedLoad) {
|
|
151
|
+
return (
|
|
152
|
+
<div className='scm-history-loading'>
|
|
153
|
+
{nls.localizeByDefault('Loading')}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return (
|
|
158
|
+
<div className='scm-history-empty'>
|
|
159
|
+
{nls.localize('theia/scm/noHistory', 'No source control history available.')}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Determine how many lanes are active for SVG width
|
|
165
|
+
const maxLane = entries.reduce((max, e) => {
|
|
166
|
+
const rowMax = Math.max(e.graphRow.lane, ...e.graphRow.edges.map(ed => Math.max(ed.fromLane, ed.toLane)));
|
|
167
|
+
return Math.max(max, rowMax);
|
|
168
|
+
}, 0);
|
|
169
|
+
const svgWidth = (maxLane + 1) * LANE_WIDTH;
|
|
170
|
+
|
|
171
|
+
const footer = loading
|
|
172
|
+
? () => <div className='scm-history-loading'>{nls.localizeByDefault('Loading')}</div>
|
|
173
|
+
: hasMore
|
|
174
|
+
? () => (
|
|
175
|
+
<div
|
|
176
|
+
className='scm-history-load-more'
|
|
177
|
+
onClick={this.handleLoadMore}
|
|
178
|
+
role='button'
|
|
179
|
+
tabIndex={0}
|
|
180
|
+
onKeyDown={this.handleLoadMoreKey}
|
|
181
|
+
>
|
|
182
|
+
{nls.localizeByDefault('Load more')}
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
: undefined;
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<Virtuoso
|
|
189
|
+
className='scm-history-graph-list'
|
|
190
|
+
data={entries as HistoryGraphEntry[]}
|
|
191
|
+
itemContent={(idx, entry) => this.renderRow(entry, idx, svgWidth)}
|
|
192
|
+
endReached={hasMore && !loading ? this.handleEndReached : undefined}
|
|
193
|
+
overscan={500}
|
|
194
|
+
components={footer ? { Footer: footer } : {}}
|
|
195
|
+
style={{ overflowX: 'hidden' }}
|
|
196
|
+
/>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
protected handleEndReached = (): void => {
|
|
201
|
+
if (this.model.hasMore && !this.model.loading) {
|
|
202
|
+
this.model.loadMore();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
protected renderRow(entry: HistoryGraphEntry, idx: number, svgWidth: number): React.ReactElement {
|
|
207
|
+
const { item, graphRow } = entry;
|
|
208
|
+
const isSelected = idx === this.selectedIndex;
|
|
209
|
+
const isExpanded = this.expandedIds.has(item.id);
|
|
210
|
+
const changes = this.expandedChanges.get(item.id);
|
|
211
|
+
// The first commit (idx === 0) on lane 0 is treated as HEAD/current
|
|
212
|
+
const isHead = idx === 0 && graphRow.lane === 0;
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<React.Fragment key={item.id}>
|
|
216
|
+
<div
|
|
217
|
+
className={`scm-history-graph-row${isSelected ? ' selected' : ''}`}
|
|
218
|
+
onClick={() => this.handleRowClick(idx, entry)}
|
|
219
|
+
onContextMenu={e => this.handleRowContextMenu(e, entry)}
|
|
220
|
+
onMouseEnter={e => this.handleRowMouseEnter(e, entry)}
|
|
221
|
+
>
|
|
222
|
+
{this.renderGraphSvg(graphRow, svgWidth, isHead)}
|
|
223
|
+
<div className='scm-history-graph-info'>
|
|
224
|
+
<span className='scm-history-subject'>{item.subject}</span>
|
|
225
|
+
{item.author && (
|
|
226
|
+
<span className='scm-history-author'>{item.author}</span>
|
|
227
|
+
)}
|
|
228
|
+
{item.references && item.references.length > 0 && (
|
|
229
|
+
<span className='scm-history-badges scm-history-badges-right'>
|
|
230
|
+
{this.renderRefBadges(item.references, entry)}
|
|
231
|
+
</span>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
{isExpanded && this.renderChangesRows(item.id, svgWidth, graphRow, changes)}
|
|
236
|
+
</React.Fragment>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
protected _markdownRenderer: MarkdownRenderer | undefined;
|
|
241
|
+
protected get markdownRenderer(): MarkdownRenderer {
|
|
242
|
+
this._markdownRenderer ||= this.markdownRendererFactory();
|
|
243
|
+
return this._markdownRenderer;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
protected handleRowMouseEnter = (e: React.MouseEvent<HTMLDivElement>, entry: HistoryGraphEntry): void => {
|
|
247
|
+
this.hoverService.requestHover({
|
|
248
|
+
content: buildHtmlTooltip(entry, this.markdownRenderer),
|
|
249
|
+
target: e.currentTarget,
|
|
250
|
+
position: 'right',
|
|
251
|
+
interactive: true,
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
protected renderChangesRows(
|
|
256
|
+
itemId: string,
|
|
257
|
+
svgWidth: number,
|
|
258
|
+
graphRow: GraphRow,
|
|
259
|
+
changes: ScmHistoryItemChange[] | 'loading' | undefined
|
|
260
|
+
): React.ReactElement {
|
|
261
|
+
if (changes === 'loading' || changes === undefined) {
|
|
262
|
+
return (
|
|
263
|
+
<div key={`${itemId}-loading`} className='scm-history-changes-loading'>
|
|
264
|
+
{nls.localizeByDefault('Loading')}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (changes.length === 0) {
|
|
270
|
+
return (
|
|
271
|
+
<div key={`${itemId}-empty`} className='scm-history-changes-empty'>
|
|
272
|
+
{nls.localize('theia/scm/noChanges', 'No changed files.')}
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<React.Fragment key={`${itemId}-changes`}>
|
|
279
|
+
{changes.map((change, ci) =>
|
|
280
|
+
this.renderChangeRow(change, ci, itemId, svgWidth, graphRow)
|
|
281
|
+
)}
|
|
282
|
+
</React.Fragment>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
protected renderChangeRow(
|
|
287
|
+
change: ScmHistoryItemChange,
|
|
288
|
+
ci: number,
|
|
289
|
+
itemId: string,
|
|
290
|
+
svgWidth: number,
|
|
291
|
+
graphRow: GraphRow
|
|
292
|
+
): React.ReactElement {
|
|
293
|
+
const rootUri = this.scmService.selectedRepository?.provider.rootUri;
|
|
294
|
+
const uri = change.modifiedUri ?? change.originalUri ?? change.uri;
|
|
295
|
+
const relativePath = getRepoRelativePath(uri, rootUri);
|
|
296
|
+
const fileName = getFileName(relativePath);
|
|
297
|
+
const dirPath = relativePath.includes('/')
|
|
298
|
+
? relativePath.slice(0, relativePath.lastIndexOf('/'))
|
|
299
|
+
: '';
|
|
300
|
+
const status = getChangeStatus(change);
|
|
301
|
+
const statusClass = status === 'A' ? 'added' : status === 'D' ? 'deleted' : status === 'R' ? 'renamed' : 'modified';
|
|
302
|
+
const resourceUri = new URI(uri);
|
|
303
|
+
const fileIcon = this.labelProvider.getIcon(resourceUri);
|
|
304
|
+
const changeKey = `${itemId}-change-${ci}`;
|
|
305
|
+
const isSelected = this.selectedChangeKey === changeKey;
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div
|
|
309
|
+
key={changeKey}
|
|
310
|
+
className={`scm-history-change-row${isSelected ? ' selected' : ''}`}
|
|
311
|
+
onClick={e => this.handleChangeClick(e, change, changeKey)}
|
|
312
|
+
role='treeitem'
|
|
313
|
+
tabIndex={-1}
|
|
314
|
+
>
|
|
315
|
+
{this.renderChangeRowSvg(graphRow, svgWidth)}
|
|
316
|
+
<div className='scm-history-change-info'>
|
|
317
|
+
<span className={`${fileIcon} file-icon scm-history-change-file-icon`} />
|
|
318
|
+
<div className='scm-history-change-name-container'>
|
|
319
|
+
<span className='name scm-history-change-name' title={relativePath}>{fileName}</span>
|
|
320
|
+
{dirPath && <span className='path scm-history-change-dir'>{dirPath}</span>}
|
|
321
|
+
</div>
|
|
322
|
+
<span className={`scm-history-change-status ${statusClass}`}>{status}</span>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
protected handleChangeClick = (e: React.MouseEvent, change: ScmHistoryItemChange, changeKey: string): void => {
|
|
329
|
+
e.stopPropagation();
|
|
330
|
+
this.selectedChangeKey = changeKey;
|
|
331
|
+
this.update();
|
|
332
|
+
this.openChange(change);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
protected openChange(change: ScmHistoryItemChange): void {
|
|
336
|
+
try {
|
|
337
|
+
if (change.originalUri && change.modifiedUri) {
|
|
338
|
+
const originalUri = new URI(change.originalUri);
|
|
339
|
+
const modifiedUri = new URI(change.modifiedUri);
|
|
340
|
+
const label = getFileName(getFilePath(change.modifiedUri));
|
|
341
|
+
open(this.openerService, DiffUris.encode(originalUri, modifiedUri, label));
|
|
342
|
+
} else if (change.modifiedUri) {
|
|
343
|
+
open(this.openerService, new URI(change.modifiedUri));
|
|
344
|
+
} else if (change.originalUri) {
|
|
345
|
+
open(this.openerService, new URI(change.originalUri));
|
|
346
|
+
} else {
|
|
347
|
+
open(this.openerService, new URI(change.uri));
|
|
348
|
+
}
|
|
349
|
+
} catch (err) {
|
|
350
|
+
console.error('ScmHistoryGraphWidget: failed to open change', err);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
protected renderChangeRowSvg(graphRow: GraphRow, svgWidth: number): React.ReactElement {
|
|
355
|
+
const commitX = graphRow.lane * LANE_WIDTH + 11;
|
|
356
|
+
const commitColor = laneColor(graphRow.color);
|
|
357
|
+
const elements: React.ReactElement[] = [];
|
|
358
|
+
let keyIdx = 0;
|
|
359
|
+
|
|
360
|
+
for (const edge of graphRow.edges) {
|
|
361
|
+
if (edge.type !== 'pass-through') {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const x = edge.fromLane * LANE_WIDTH + 11;
|
|
365
|
+
const color = laneColor(edge.color);
|
|
366
|
+
elements.push(
|
|
367
|
+
<path
|
|
368
|
+
key={`pass-${keyIdx++}`}
|
|
369
|
+
fill='none'
|
|
370
|
+
strokeWidth='1px'
|
|
371
|
+
strokeLinecap='round'
|
|
372
|
+
d={`M ${x} 0 V 22`}
|
|
373
|
+
style={{ stroke: color }}
|
|
374
|
+
/>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (graphRow.hasContinuation) {
|
|
379
|
+
elements.push(
|
|
380
|
+
<path
|
|
381
|
+
key={`commit-${keyIdx++}`}
|
|
382
|
+
fill='none'
|
|
383
|
+
strokeWidth='1px'
|
|
384
|
+
strokeLinecap='round'
|
|
385
|
+
d={`M ${commitX} 0 V 22`}
|
|
386
|
+
style={{ stroke: commitColor }}
|
|
387
|
+
/>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
<svg
|
|
393
|
+
className='scm-history-graph-svg'
|
|
394
|
+
style={{ height: `${ROW_HEIGHT}px`, width: `${svgWidth}px` }}
|
|
395
|
+
>
|
|
396
|
+
{elements}
|
|
397
|
+
</svg>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Renders the SVG graph column for a commit row, matching VS Code's SVG structure:
|
|
403
|
+
*
|
|
404
|
+
* - Each lane is 22px wide; circle cx = lane * 22 + 11, cy = 11
|
|
405
|
+
* - Pass-through lanes: full vertical line M x 0 V 22
|
|
406
|
+
* - Commit lane: top segment (M commitX 0 V 11) + bottom segment (M commitX 11 V 22)
|
|
407
|
+
* - HEAD/current commit (first entry): r=7 with inner dot, NO top segment
|
|
408
|
+
* - Normal commit: r=5
|
|
409
|
+
* - Branch-out edge: diagonal S-curve bezier from commit position (commitX, cy)
|
|
410
|
+
* sweeping down to the new lane at the bottom: M commitX cy C commitX 22 newX cy newX 22
|
|
411
|
+
* - Merge-in edge: bezier from the source lane at the top curving into
|
|
412
|
+
* the commit position: M srcX 0 C srcX cy commitX cy commitX cy
|
|
413
|
+
*/
|
|
414
|
+
protected renderGraphSvg(row: GraphRow, svgWidth: number, isHead: boolean): React.ReactElement {
|
|
415
|
+
const commitX = row.lane * LANE_WIDTH + 11;
|
|
416
|
+
const cy = DOT_CY;
|
|
417
|
+
const commitColor = laneColor(row.color);
|
|
418
|
+
|
|
419
|
+
const elements: React.ReactElement[] = [];
|
|
420
|
+
let keyIdx = 0;
|
|
421
|
+
|
|
422
|
+
// Pass-through lines: straight vertical line through the full row height
|
|
423
|
+
for (const edge of row.edges) {
|
|
424
|
+
if (edge.type !== 'pass-through') {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const x = edge.fromLane * LANE_WIDTH + 11;
|
|
428
|
+
const color = laneColor(edge.color);
|
|
429
|
+
elements.push(
|
|
430
|
+
<path
|
|
431
|
+
key={`pass-${keyIdx++}`}
|
|
432
|
+
fill='none'
|
|
433
|
+
strokeWidth='1px'
|
|
434
|
+
strokeLinecap='round'
|
|
435
|
+
d={`M ${x} 0 V 22`}
|
|
436
|
+
style={{ stroke: color }}
|
|
437
|
+
/>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Merge-in edges: source lane at top curves into the commit position (mid-row)
|
|
442
|
+
for (const edge of row.edges) {
|
|
443
|
+
if (edge.type !== 'merge-in') {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const srcX = edge.fromLane * LANE_WIDTH + 11;
|
|
447
|
+
const color = laneColor(edge.color);
|
|
448
|
+
// Cubic bezier: start at (srcX, 0), control points pull both ends to mid-row,
|
|
449
|
+
// end at commit position (commitX, cy)
|
|
450
|
+
const d = `M ${srcX} 0 C ${srcX} ${cy}, ${commitX} ${cy}, ${commitX} ${cy}`;
|
|
451
|
+
elements.push(
|
|
452
|
+
<path
|
|
453
|
+
key={`merge-${keyIdx++}`}
|
|
454
|
+
fill='none'
|
|
455
|
+
strokeWidth='1px'
|
|
456
|
+
strokeLinecap='round'
|
|
457
|
+
d={d}
|
|
458
|
+
style={{ stroke: color }}
|
|
459
|
+
/>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Commit lane lines — split at cy=11
|
|
464
|
+
// Top segment: only drawn if there is an incoming line from above
|
|
465
|
+
// (i.e. this commit was referenced as a parent by an earlier row)
|
|
466
|
+
if (row.hasTopLine) {
|
|
467
|
+
elements.push(
|
|
468
|
+
<path
|
|
469
|
+
key={`top-${keyIdx++}`}
|
|
470
|
+
fill='none'
|
|
471
|
+
strokeWidth='1px'
|
|
472
|
+
strokeLinecap='round'
|
|
473
|
+
d={`M ${commitX} 0 V ${cy}`}
|
|
474
|
+
style={{ stroke: commitColor }}
|
|
475
|
+
/>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
// Bottom segment: only drawn if the lane continues to a parent below
|
|
479
|
+
if (row.hasContinuation) {
|
|
480
|
+
elements.push(
|
|
481
|
+
<path
|
|
482
|
+
key={`bottom-${keyIdx++}`}
|
|
483
|
+
fill='none'
|
|
484
|
+
strokeWidth='1px'
|
|
485
|
+
strokeLinecap='round'
|
|
486
|
+
d={`M ${commitX} ${cy} V 22`}
|
|
487
|
+
style={{ stroke: commitColor }}
|
|
488
|
+
/>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Branch-out edges: commit position (mid-row) curves down to a new lane at bottom
|
|
493
|
+
for (const edge of row.edges) {
|
|
494
|
+
if (edge.type !== 'branch-out') {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const dstX = edge.toLane * LANE_WIDTH + 11;
|
|
498
|
+
const color = laneColor(edge.color);
|
|
499
|
+
// Diagonal S-curve bezier: CP1 keeps x at srcX while y goes to bottom;
|
|
500
|
+
// CP2 keeps y at cy while x reaches dstX — produces a smooth sweep
|
|
501
|
+
// down-and-right (or left) from the commit circle to the target lane.
|
|
502
|
+
const srcX = edge.fromLane * LANE_WIDTH + 11;
|
|
503
|
+
const d = `M ${srcX} ${cy} C ${srcX} 22, ${dstX} ${cy}, ${dstX} 22`;
|
|
504
|
+
elements.push(
|
|
505
|
+
<path
|
|
506
|
+
key={`branch-${keyIdx++}`}
|
|
507
|
+
fill='none'
|
|
508
|
+
strokeWidth='1px'
|
|
509
|
+
strokeLinecap='round'
|
|
510
|
+
d={d}
|
|
511
|
+
style={{ stroke: color }}
|
|
512
|
+
/>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Commit circle — drawn last so it renders on top of all lines
|
|
517
|
+
if (isHead) {
|
|
518
|
+
elements.push(
|
|
519
|
+
<circle
|
|
520
|
+
key={`circle-${keyIdx++}`}
|
|
521
|
+
cx={commitX}
|
|
522
|
+
cy={cy}
|
|
523
|
+
r={7}
|
|
524
|
+
style={{ strokeWidth: '2px', fill: commitColor }}
|
|
525
|
+
/>
|
|
526
|
+
);
|
|
527
|
+
elements.push(
|
|
528
|
+
<circle
|
|
529
|
+
key={`inner-${keyIdx++}`}
|
|
530
|
+
cx={commitX}
|
|
531
|
+
cy={cy}
|
|
532
|
+
r={2}
|
|
533
|
+
style={{ strokeWidth: '4px', fill: 'var(--theia-editor-background)', stroke: 'var(--theia-editor-background)' }}
|
|
534
|
+
/>
|
|
535
|
+
);
|
|
536
|
+
} else {
|
|
537
|
+
elements.push(
|
|
538
|
+
<circle
|
|
539
|
+
key={`circle-${keyIdx++}`}
|
|
540
|
+
cx={commitX}
|
|
541
|
+
cy={cy}
|
|
542
|
+
r={5}
|
|
543
|
+
style={{ strokeWidth: '2px', fill: commitColor }}
|
|
544
|
+
/>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return (
|
|
549
|
+
<svg
|
|
550
|
+
className='scm-history-graph-svg'
|
|
551
|
+
style={{ height: `${ROW_HEIGHT}px`, width: `${svgWidth}px` }}
|
|
552
|
+
>
|
|
553
|
+
{elements}
|
|
554
|
+
</svg>
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
protected renderRefBadges(refs?: readonly ScmHistoryItemRef[], entry?: HistoryGraphEntry): React.ReactNode {
|
|
559
|
+
if (!refs || refs.length === 0) {
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const laneColorValue = entry ? laneColor(entry.graphRow.color) : undefined;
|
|
564
|
+
const deduplicated = deduplicateRefs(refs);
|
|
565
|
+
const style: React.CSSProperties = laneColorValue
|
|
566
|
+
? { backgroundColor: laneColorValue, color: 'var(--theia-scmGraph-historyItemRefForeground, var(--theia-badge-foreground))' }
|
|
567
|
+
: {};
|
|
568
|
+
|
|
569
|
+
const badges: React.ReactElement[] = [];
|
|
570
|
+
for (const info of deduplicated) {
|
|
571
|
+
if (badges.length >= 3) {
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
const { ref, hasBoth } = info as DeduplicatedRef;
|
|
575
|
+
const isTag = isTagRef(ref);
|
|
576
|
+
const isRemote = isRemoteRef(ref);
|
|
577
|
+
const onContextMenu = entry ? (e: React.MouseEvent) => this.handleRefBadgeContextMenu(e, entry, ref) : undefined;
|
|
578
|
+
|
|
579
|
+
if (isTag) {
|
|
580
|
+
badges.push(renderJsxRefBadge(ref, 'codicon-tag', false, style, ref.id, onContextMenu));
|
|
581
|
+
} else if (isRemote) {
|
|
582
|
+
badges.push(renderJsxRefBadge(ref, 'codicon-cloud', true, style, ref.id, onContextMenu));
|
|
583
|
+
} else {
|
|
584
|
+
badges.push(renderJsxRefBadge(ref, 'codicon-git-branch', true, style, ref.id, onContextMenu));
|
|
585
|
+
if (hasBoth && badges.length < 3) {
|
|
586
|
+
badges.push(
|
|
587
|
+
<span
|
|
588
|
+
key={`${ref.id}-cloud`}
|
|
589
|
+
className='scm-history-ref-badge scm-history-ref-badge-cloud'
|
|
590
|
+
title={ref.description ?? ref.name}
|
|
591
|
+
style={style}
|
|
592
|
+
>
|
|
593
|
+
<i className='codicon codicon-cloud scm-history-ref-icon' />
|
|
594
|
+
</span>
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return badges;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
protected handleRowContextMenu = (e: React.MouseEvent, entry: HistoryGraphEntry): void => {
|
|
603
|
+
e.preventDefault();
|
|
604
|
+
e.stopPropagation();
|
|
605
|
+
const repo = this.scmService.selectedRepository;
|
|
606
|
+
if (!repo) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const { item } = entry;
|
|
610
|
+
if (item.references && item.references.length > 0) {
|
|
611
|
+
this.scmContextKeys.scmHistoryItemRef.set(item.references[0].id);
|
|
612
|
+
} else {
|
|
613
|
+
this.scmContextKeys.scmHistoryItemRef.set(undefined);
|
|
614
|
+
}
|
|
615
|
+
const sourceControlHandle = repo.provider.handle;
|
|
616
|
+
const args = sourceControlHandle !== undefined
|
|
617
|
+
? [{ sourceControlHandle }, { sourceControlHandle, id: item.id, type: 'historyItem' as const }]
|
|
618
|
+
: [];
|
|
619
|
+
this.contextMenuRenderer.render({
|
|
620
|
+
menuPath: SCM_HISTORY_ITEM_CONTEXT_MENU,
|
|
621
|
+
anchor: e.nativeEvent,
|
|
622
|
+
args,
|
|
623
|
+
context: this.node
|
|
624
|
+
});
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
protected handleRefBadgeContextMenu = (e: React.MouseEvent, entry: HistoryGraphEntry, ref: ScmHistoryItemRef): void => {
|
|
628
|
+
e.preventDefault();
|
|
629
|
+
e.stopPropagation();
|
|
630
|
+
const repo = this.scmService.selectedRepository;
|
|
631
|
+
if (!repo) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
this.scmContextKeys.scmHistoryItemRef.set(ref.id);
|
|
635
|
+
const sourceControlHandle = repo.provider.handle;
|
|
636
|
+
const args = sourceControlHandle !== undefined
|
|
637
|
+
? [{ sourceControlHandle }, { sourceControlHandle, id: ref.id, type: 'historyItemRef' as const }]
|
|
638
|
+
: [];
|
|
639
|
+
this.contextMenuRenderer.render({
|
|
640
|
+
menuPath: SCM_HISTORY_ITEM_REF_CONTEXT_MENU,
|
|
641
|
+
anchor: e.nativeEvent,
|
|
642
|
+
args,
|
|
643
|
+
context: this.node
|
|
644
|
+
});
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
protected handleRowClick = (idx: number, entry: HistoryGraphEntry): void => {
|
|
648
|
+
this.selectedIndex = idx;
|
|
649
|
+
const { item } = entry;
|
|
650
|
+
|
|
651
|
+
if (this.expandedIds.has(item.id)) {
|
|
652
|
+
this.expandedIds.delete(item.id);
|
|
653
|
+
const cts = this.loadChangesCts.get(item.id);
|
|
654
|
+
if (cts) {
|
|
655
|
+
cts.cancel();
|
|
656
|
+
cts.dispose();
|
|
657
|
+
this.loadChangesCts.delete(item.id);
|
|
658
|
+
}
|
|
659
|
+
} else {
|
|
660
|
+
this.expandedIds.add(item.id);
|
|
661
|
+
if (!this.expandedChanges.has(item.id)) {
|
|
662
|
+
this.loadChanges(entry);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
this.update();
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
protected async loadChanges(entry: HistoryGraphEntry): Promise<void> {
|
|
670
|
+
const { item } = entry;
|
|
671
|
+
const provider = this.model.provider;
|
|
672
|
+
if (!provider) {
|
|
673
|
+
this.expandedChanges.set(item.id, []);
|
|
674
|
+
this.update();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
this.expandedChanges.set(item.id, 'loading');
|
|
679
|
+
this.update();
|
|
680
|
+
|
|
681
|
+
const cts = new CancellationTokenSource();
|
|
682
|
+
this.loadChangesCts.set(item.id, cts);
|
|
683
|
+
this.toDispose.push(cts);
|
|
684
|
+
try {
|
|
685
|
+
const parentId = item.parentIds?.[0];
|
|
686
|
+
const changes = await provider.provideHistoryItemChanges(item.id, parentId, cts.token);
|
|
687
|
+
if (!cts.token.isCancellationRequested) {
|
|
688
|
+
this.expandedChanges.set(item.id, changes ?? []);
|
|
689
|
+
this.update();
|
|
690
|
+
}
|
|
691
|
+
} catch (err) {
|
|
692
|
+
if (!cts.token.isCancellationRequested) {
|
|
693
|
+
console.error('ScmHistoryGraphWidget: failed to load changes', err);
|
|
694
|
+
this.expandedChanges.set(item.id, []);
|
|
695
|
+
this.update();
|
|
696
|
+
}
|
|
697
|
+
} finally {
|
|
698
|
+
this.loadChangesCts.delete(item.id);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
protected handleLoadMore = (): void => {
|
|
703
|
+
this.model.loadMore();
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
protected handleLoadMoreKey = (e: React.KeyboardEvent): void => {
|
|
707
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
708
|
+
this.model.loadMore();
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
}
|