Skip to content

Commit 01d59f1

Browse files
authored
Add 'Changes in Parent' resource group to SCM view (#100)
1 parent 62ad706 commit 01d59f1

5 files changed

Lines changed: 156 additions & 9 deletions

File tree

apps/desktop/extensions/git/src/decorationProvider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ class GitDecorationProvider implements FileDecorationProvider {
125125
this.collectDecorationData(this.repository.untrackedGroup, newDecorations);
126126
this.collectDecorationData(this.repository.workingTreeGroup, newDecorations);
127127
this.collectDecorationData(this.repository.mergeGroup, newDecorations);
128+
this.collectDecorationData(this.repository.parentChangesGroup, newDecorations);
128129
this.collectSubmoduleDecorationData(newDecorations);
129130

130131
const uris = new Set([...this.decorations.keys()].concat([...newDecorations.keys()]));

apps/desktop/extensions/git/src/repository.ts

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,13 @@ export const enum ResourceGroupType {
5050
Merge,
5151
Index,
5252
WorkingTree,
53-
Untracked
53+
Untracked,
54+
ParentChanges
5455
}
5556

57+
export const PARENT_CHANGES_GROUP_ID = 'parentChanges';
58+
export const PARENT_CHANGE_CONTEXT_VALUE = 'parentChange';
59+
5660
export class Resource implements SourceControlResourceState {
5761

5862
static getStatusLetter(type: Status): string {
@@ -189,7 +193,12 @@ export class Resource implements SourceControlResourceState {
189193
get type(): Status { return this._type; }
190194
get original(): Uri { return this._resourceUri; }
191195
get renameResourceUri(): Uri | undefined { return this._renameResourceUri; }
192-
get contextValue(): string | undefined { return this._repositoryKind; }
196+
get contextValue(): string | undefined {
197+
if (this._resourceGroupType === ResourceGroupType.ParentChanges) {
198+
return PARENT_CHANGE_CONTEXT_VALUE;
199+
}
200+
return this._repositoryKind;
201+
}
193202

194203
private static Icons = {
195204
light: {
@@ -588,6 +597,14 @@ class ResourceCommandResolver {
588597
}
589598

590599
private getLeftResource(resource: Resource): ModifiedOrOriginal {
600+
// Parent changes: left = file at origin/<default_branch>
601+
if (resource.resourceGroupType === ResourceGroupType.ParentChanges) {
602+
const ref = this.repository.parentBranchRef;
603+
if (!ref) { return {}; }
604+
if (resource.type === Status.INDEX_ADDED || resource.type === Status.UNTRACKED) { return {}; }
605+
return { original: toGitUri(resource.original, ref) };
606+
}
607+
591608
switch (resource.type) {
592609
case Status.INDEX_MODIFIED:
593610
case Status.INDEX_RENAMED:
@@ -606,6 +623,12 @@ class ResourceCommandResolver {
606623
}
607624

608625
private getRightResource(resource: Resource): ModifiedOrOriginal {
626+
// Parent changes: right = working tree file
627+
if (resource.resourceGroupType === ResourceGroupType.ParentChanges) {
628+
if (resource.type === Status.DELETED) { return {}; }
629+
return { modified: resource.resourceUri };
630+
}
631+
609632
switch (resource.type) {
610633
case Status.INDEX_MODIFIED:
611634
case Status.INDEX_ADDED:
@@ -649,6 +672,10 @@ class ResourceCommandResolver {
649672
private getTitle(resource: Resource): string {
650673
const basename = path.basename(resource.resourceUri.fsPath);
651674

675+
if (resource.resourceGroupType === ResourceGroupType.ParentChanges) {
676+
return l10n.t('{0} (Parent)', basename);
677+
}
678+
652679
switch (resource.type) {
653680
case Status.INDEX_MODIFIED:
654681
case Status.INDEX_RENAMED:
@@ -748,6 +775,12 @@ export class Repository implements Disposable {
748775
private _untrackedGroup: SourceControlResourceGroup;
749776
get untrackedGroup(): GitResourceGroup { return this._untrackedGroup as GitResourceGroup; }
750777

778+
private _parentChangesGroup: SourceControlResourceGroup;
779+
get parentChangesGroup(): GitResourceGroup { return this._parentChangesGroup as GitResourceGroup; }
780+
781+
private _parentBranchRef: string | undefined;
782+
get parentBranchRef(): string | undefined { return this._parentBranchRef; }
783+
751784
private _EMPTY_TREE: string | undefined;
752785

753786
private _HEAD: Branch | undefined;
@@ -894,6 +927,9 @@ export class Repository implements Disposable {
894927
private branchProtection = new Map<string, BranchProtectionMatcher[]>();
895928
private commitCommandCenter: CommitCommandsCenter;
896929
private resourceCommandResolver = new ResourceCommandResolver(this);
930+
private static readonly PARENT_FETCH_THROTTLE_MS = 60_000;
931+
private _lastParentFetchTime = 0;
932+
private _parentChangesGeneration = 0;
897933
private updateModelStateCancellationTokenSource: CancellationTokenSource | undefined;
898934
private disposables: Disposable[] = [];
899935

@@ -997,6 +1033,7 @@ export class Repository implements Disposable {
9971033
this._indexGroup = this._sourceControl.createResourceGroup('index', l10n.t('Staged Changes'), { multiDiffEditorEnableViewChanges: true });
9981034
this._workingTreeGroup = this._sourceControl.createResourceGroup('workingTree', l10n.t('Changes'), { multiDiffEditorEnableViewChanges: true });
9991035
this._untrackedGroup = this._sourceControl.createResourceGroup('untracked', l10n.t('Untracked Changes'), { multiDiffEditorEnableViewChanges: true });
1036+
this._parentChangesGroup = this._sourceControl.createResourceGroup(PARENT_CHANGES_GROUP_ID, l10n.t('Changes in Parent'));
10001037

10011038
const updateIndexGroupVisibility = () => {
10021039
const config = workspace.getConfiguration('git', root);
@@ -1033,11 +1070,13 @@ export class Repository implements Disposable {
10331070

10341071
this.mergeGroup.hideWhenEmpty = true;
10351072
this.untrackedGroup.hideWhenEmpty = true;
1073+
this.parentChangesGroup.hideWhenEmpty = true;
10361074

10371075
this.disposables.push(this.mergeGroup);
10381076
this.disposables.push(this.indexGroup);
10391077
this.disposables.push(this.workingTreeGroup);
10401078
this.disposables.push(this.untrackedGroup);
1079+
this.disposables.push(this.parentChangesGroup);
10411080

10421081
// Don't allow auto-fetch in untrusted workspaces
10431082
if (workspace.isTrusted) {
@@ -2846,6 +2885,9 @@ export class Repository implements Disposable {
28462885
this._refs = refs;
28472886
this._updateResourceGroupsState(resourceGroups);
28482887

2888+
// Update parent branch changes (non-blocking)
2889+
this._updateParentChanges().catch(() => { /* silently ignore */ });
2890+
28492891
this._onDidChangeStatus.fire();
28502892
}
28512893
catch (err) {
@@ -2873,6 +2915,96 @@ export class Repository implements Disposable {
28732915
this.setCountBadge();
28742916
}
28752917

2918+
private async _updateParentChanges(): Promise<void> {
2919+
const generation = ++this._parentChangesGeneration;
2920+
2921+
try {
2922+
const defaultBranch = await this.getDefaultBranch();
2923+
if (!defaultBranch || generation !== this._parentChangesGeneration) {
2924+
if (generation === this._parentChangesGeneration) {
2925+
this.parentChangesGroup.resourceStates = [];
2926+
}
2927+
return;
2928+
}
2929+
2930+
const parentRef = `${defaultBranch.remote}/${defaultBranch.name}`;
2931+
2932+
// Fetch latest from the remote default branch (throttled to once per 60s)
2933+
const now = Date.now();
2934+
if (now - this._lastParentFetchTime > Repository.PARENT_FETCH_THROTTLE_MS) {
2935+
this._lastParentFetchTime = now;
2936+
try {
2937+
await this.fetch({ remote: defaultBranch.remote!, ref: defaultBranch.name });
2938+
} catch {
2939+
// Fetch may fail (offline, no remote, etc.) — continue with stale ref
2940+
}
2941+
}
2942+
2943+
if (generation !== this._parentChangesGeneration) { return; }
2944+
2945+
// Use merge-base so the diff matches what a PR would show:
2946+
// only files changed on THIS branch since it diverged from origin/main
2947+
const mergeBase = await this.getMergeBase(parentRef, 'HEAD');
2948+
if (!mergeBase || generation !== this._parentChangesGeneration) {
2949+
if (generation === this._parentChangesGeneration) {
2950+
this.parentChangesGroup.resourceStates = [];
2951+
}
2952+
return;
2953+
}
2954+
this._parentBranchRef = mergeBase;
2955+
2956+
// Diff from merge-base to working tree (committed + staged + unstaged)
2957+
const changes = await this.diffBetweenWithStats2(mergeBase);
2958+
2959+
if (generation !== this._parentChangesGeneration) { return; }
2960+
2961+
const config = workspace.getConfiguration('git');
2962+
const useIcons = !config.get<boolean>('decorations.enabled', true);
2963+
2964+
// Build a set of tracked paths so we can add untracked files without duplicates
2965+
const trackedPaths = new Set(changes.map(c => c.uri.fsPath));
2966+
2967+
const resources: Resource[] = changes.map(change =>
2968+
new Resource(
2969+
this.resourceCommandResolver,
2970+
ResourceGroupType.ParentChanges,
2971+
change.originalUri,
2972+
change.status,
2973+
useIcons,
2974+
change.renameUri,
2975+
this.kind,
2976+
)
2977+
);
2978+
2979+
// Add untracked files (from existing groups, already populated by getStatus)
2980+
const untrackedResources = [
2981+
...this.untrackedGroup.resourceStates,
2982+
...this.workingTreeGroup.resourceStates.filter(r => r.type === Status.UNTRACKED),
2983+
];
2984+
for (const r of untrackedResources) {
2985+
if (!trackedPaths.has(r.resourceUri.fsPath)) {
2986+
trackedPaths.add(r.resourceUri.fsPath);
2987+
resources.push(new Resource(
2988+
this.resourceCommandResolver,
2989+
ResourceGroupType.ParentChanges,
2990+
r.resourceUri,
2991+
Status.UNTRACKED,
2992+
useIcons,
2993+
undefined,
2994+
this.kind,
2995+
));
2996+
}
2997+
}
2998+
2999+
this.parentChangesGroup.resourceStates = resources;
3000+
this._onDidChangeStatus.fire();
3001+
} catch {
3002+
if (generation === this._parentChangesGeneration) {
3003+
this.parentChangesGroup.resourceStates = [];
3004+
}
3005+
}
3006+
}
3007+
28763008
private async getStatus(cancellationToken?: CancellationToken): Promise<GitResourceGroups> {
28773009
if (cancellationToken && cancellationToken.isCancellationRequested) {
28783010
throw new CancellationError();

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "code-oss-dev",
3-
"version": "0.2.11",
3+
"version": "0.2.12",
44
"distro": "d48ce3e61fa56a702d13bb450ceb3532620dbd46",
55
"author": {
66
"name": "Workstream Labs"

apps/desktop/src/vs/workbench/contrib/scm/browser/scmViewPane.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { ViewPane, IViewPaneOptions, ViewAction } from '../../../browser/parts/v
1111
import { append, $, clearNode, isPointerEvent, isActiveElement } from '../../../../base/browser/dom.js';
1212
import { asCSSUrl } from '../../../../base/browser/cssValue.js';
1313
import { IListVirtualDelegate, IIdentityProvider } from '../../../../base/browser/ui/list/list.js';
14-
import { ISCMResourceGroup, ISCMResource, ISCMRepository, ISCMInput, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, ISCMService, VIEW_PANE_ID, ISCMActionButton, ISCMActionButtonDescriptor, ISCMRepositorySortKey, ViewMode, ISCMRepositorySelectionMode } from '../common/scm.js';
14+
import { ISCMResourceGroup, ISCMResource, ISCMRepository, ISCMInput, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, ISCMService, VIEW_PANE_ID, PARENT_CHANGES_GROUP_ID, ISCMActionButton, ISCMActionButtonDescriptor, ISCMRepositorySortKey, ViewMode, ISCMRepositorySelectionMode } from '../common/scm.js';
1515
import { ResourceLabels, IResourceLabel, IFileLabelOptions } from '../../../browser/labels.js';
1616
import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js';
1717
import { IEditorService } from '../../../services/editor/common/editorService.js';
@@ -424,11 +424,15 @@ class ResourceGroupRenderer implements ICompressibleTreeRenderer<ISCMResourceGro
424424
template.name.textContent = group.label;
425425
template.count.setCount(group.resources.length);
426426

427-
const menus = this.scmViewService.menus.getRepositoryMenus(group.provider);
428-
template.elementDisposables.add(connectPrimaryMenu(menus.getResourceGroupMenu(group), primary => {
429-
template.actionBar.setActions(primary);
430-
}, 'inline'));
431-
template.actionBar.context = group;
427+
if (group.id === PARENT_CHANGES_GROUP_ID) {
428+
template.actionBar.setActions([]);
429+
} else {
430+
const menus = this.scmViewService.menus.getRepositoryMenus(group.provider);
431+
template.elementDisposables.add(connectPrimaryMenu(menus.getResourceGroupMenu(group), primary => {
432+
template.actionBar.setActions(primary);
433+
}, 'inline'));
434+
template.actionBar.context = group;
435+
}
432436
}
433437

434438
renderCompressedElements(node: ITreeNode<ICompressedTreeNode<ISCMResourceGroup>, FuzzyScore>): void {
@@ -615,6 +619,15 @@ class ResourceRenderer implements ICompressibleTreeRenderer<ISCMResource | IReso
615619
}
616620

617621
private _renderActionBar(template: ResourceTemplate, resourceOrFolder: ISCMResource | IResourceNode<ISCMResource, ISCMResourceGroup>, menu: IMenu): void {
622+
// Hide inline actions for parentChanges resources
623+
const group = ResourceTree.isResourceNode(resourceOrFolder)
624+
? resourceOrFolder.context
625+
: resourceOrFolder.resourceGroup;
626+
if (group.id === PARENT_CHANGES_GROUP_ID) {
627+
template.actionBar.setActions([]);
628+
return;
629+
}
630+
618631
if (!template.actionBarMenu || template.actionBarMenu !== menu) {
619632
template.actionBarMenu = menu;
620633
template.actionBarMenuListener.value = connectPrimaryMenu(menu, primary => {

apps/desktop/src/vs/workbench/contrib/scm/common/scm.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const VIEWLET_ID = 'workbench.view.scm';
2222
export const VIEW_PANE_ID = 'workbench.scm';
2323
export const REPOSITORIES_VIEW_PANE_ID = 'workbench.scm.repositories';
2424
export const HISTORY_VIEW_PANE_ID = 'workbench.scm.history';
25+
export const PARENT_CHANGES_GROUP_ID = 'parentChanges';
2526

2627
export const enum ViewMode {
2728
List = 'list',

0 commit comments

Comments
 (0)