diff --git a/CHANGELOG.md b/CHANGELOG.md index 037ecf6..028cfee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Adds editor link highlighting to the _File Changes_ annotations for easier discovery of the added or changed lines - Adds a `line` option to `gitlens.changes.locations` setting to specify whether to add a line highlight to the _File Changes_ annotations - Adds "vanilla" [Gerrit](https://www.gerritcodereview.com/) remote provider support — closes [#1953](https://github.com/gitkraken/vscode-gitlens/issues/1953) thanks to [PR #1954](https://github.com/gitkraken/vscode-gitlens/pull/1954) by Felipe Santos ([@felipecrs](https://github.com/felipecrs)) +- Adds "Oldest first" toggle to Interactive Rebase — closes [#1190](https://github.com/gitkraken/vscode-gitlens/issues/1190) + - Adds a `gitlens.rebaseEditor.ordering` setting to specify how Git commits are displayed in the _Interactive Rebase Editor_ ## Changed diff --git a/package.json b/package.json index 20605c6..840252a 100644 --- a/package.json +++ b/package.json @@ -2065,6 +2065,28 @@ } }, { + "id": "rebase-editor", + "title": "Interactive Rebase Editor", + "order": 105, + "properties": { + "gitlens.rebaseEditor.ordering": { + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "enumDescriptions": [ + "Shows oldest commit first", + "Shows newest commit first" + ], + "markdownDescription": "Specifies how Git commits are displayed in the _Interactive Rebase Editor_", + "scope": "window", + "order": 10 + } + } + }, + { "id": "git-command-palette", "title": "Git Command Palette", "order": 110, diff --git a/src/config.ts b/src/config.ts index 389f82c..25672ee 100644 --- a/src/config.ts +++ b/src/config.ts @@ -121,6 +121,9 @@ export interface Config { url: string | null; strictSSL: boolean; } | null; + rebaseEditor: { + ordering: 'asc' | 'desc'; + }; remotes: RemotesConfig[] | null; showWelcomeOnInstall: boolean; showWhatsNewAfterUpgrades: boolean; diff --git a/src/webviews/apps/rebase/rebase.html b/src/webviews/apps/rebase/rebase.html index fddd89f..83607e8 100644 --- a/src/webviews/apps/rebase/rebase.html +++ b/src/webviews/apps/rebase/rebase.html @@ -9,6 +9,21 @@

GitLens Interactive Rebase

+
+ + + + +
diff --git a/src/webviews/apps/rebase/rebase.scss b/src/webviews/apps/rebase/rebase.scss index d4a6d6a..af27397 100644 --- a/src/webviews/apps/rebase/rebase.scss +++ b/src/webviews/apps/rebase/rebase.scss @@ -208,8 +208,6 @@ $entry-padding: 5px; &.entry--base, &.entry--done { - margin-top: 5px; - & > .entry-action { opacity: 0.8; } @@ -249,8 +247,17 @@ $entry-padding: 5px; } } + &.entry--done { + margin-top: 5px; + } + &.entry--base { - margin-top: 10px; + .entries--ascending & { + margin-bottom: 10px; + } + .entries:not(.entries--ascending) & { + margin-top: 10px; + } .vscode-dark & { background: rgba(255, 255, 255, 0.1); @@ -263,8 +270,10 @@ $entry-padding: 5px; } } - .entries--base &:nth-last-of-type(2), - :not(.entries--base) &:nth-last-of-type(1) { + .entries--base:not(.entries--ascending) &:nth-last-of-type(2), + .entries:not(.entries--base):not(.entries--ascending) &:nth-last-of-type(1), + .entries--base.entries--ascending &:nth-of-type(2), + .entries--ascending:not(.entries--base) &:nth-of-type(1) { & select { & > option[value='squash'], & > option[value='fixup'] { @@ -399,4 +408,63 @@ $entry-padding: 5px; } } +.toggle { + display: inline-flex; + flex-direction: row-reverse; + align-items: center; + gap: 0.5em; + + &__input, + &__indicator { + width: 2.4em; + height: 1.4em; + } + + &__input { + position: absolute; + appearance: none; + opacity: 0; + border-radius: 1em; + padding: 0; + + &:focus { + border-radius: 1em; + } + } + + &__indicator { + position: relative; + pointer-events: none; + display: block; + flex: none; + border-radius: 1em; + background-color: var(--color-background--lighten-075); + border: 1px solid var(--color-background--lighten-075); + + &::before { + content: ''; + top: 0.1em; + left: 0.1em; + position: absolute; + width: 1.2em; + height: 1.2em; + border-radius: 100%; + background-color: var(--color-button-foreground); + + :checked ~ & { + transform: translateX(1em); + } + } + + :checked ~ & { + background-color: var(--color-highlight); + } + + :focus ~ & { + border-color: var(--color-background); + box-shadow: 0 0 0 1px var(--color-focus-border); + } + } +} + @import '../shared/codicons'; diff --git a/src/webviews/apps/rebase/rebase.ts b/src/webviews/apps/rebase/rebase.ts index a7a49e0..a4f14cc 100644 --- a/src/webviews/apps/rebase/rebase.ts +++ b/src/webviews/apps/rebase/rebase.ts @@ -10,6 +10,7 @@ import { MoveEntryCommandType, RebaseEntry, RebaseEntryAction, + ReorderCommandType, StartCommandType, State, SwitchCommandType, @@ -61,7 +62,10 @@ class RebaseEditor extends App { let squashing = false; let squashToHere = false; - const $entries = document.querySelectorAll('li[data-ref]'); + const $entries = [...document.querySelectorAll('li[data-ref]')]; + if (this.state.ascending) { + $entries.reverse(); + } for (const $entry of $entries) { squashToHere = false; if ($entry.classList.contains('entry--squash') || $entry.classList.contains('entry--fixup')) { @@ -86,7 +90,11 @@ class RebaseEditor extends App { const ref = e.item.dataset.ref; if (ref != null) { - this.moveEntry(ref, e.newIndex, false); + let indexTarget = e.newIndex; + if (this.state.ascending && e.oldIndex) { + indexTarget = this.getEntryIndex(ref) + (indexTarget - e.oldIndex) * -1; + } + this.moveEntry(ref, indexTarget, false); document.querySelectorAll(`li[data-ref="${ref}"]`)[0]?.focus(); } @@ -142,12 +150,17 @@ class RebaseEditor extends App { } } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { if (!e.metaKey && !e.ctrlKey && !e.shiftKey) { + const advance = + (e.key === 'ArrowDown' && !this.state.ascending) || + (e.key === 'ArrowUp' && this.state.ascending) + ? 1 + : -1; if (e.altKey) { const ref = target.dataset.ref; if (ref) { e.stopPropagation(); - this.moveEntry(ref, e.key === 'ArrowDown' ? 1 : -1, true); + this.moveEntry(ref, advance, true); } } else { if (this.state == null) return; @@ -157,7 +170,7 @@ class RebaseEditor extends App { e.preventDefault(); - let index = this.getEntryIndex(ref) + (e.key === 'ArrowDown' ? 1 : -1); + let index = this.getEntryIndex(ref) + advance; if (index < 0) { index = this.state.entries.length - 1; } else if (index === this.state.entries.length) { @@ -210,6 +223,9 @@ class RebaseEditor extends App { } }), DOM.on('select[data-ref]', 'input', (e, target: HTMLSelectElement) => this.onSelectChanged(target)), + DOM.on('input[data-action="reorder"]', 'input', (e, target: HTMLInputElement) => + this.onOrderChanged(target), + ), ); return disposables; @@ -269,6 +285,14 @@ class RebaseEditor extends App { this.sendCommand(SwitchCommandType, undefined); } + private onOrderChanged($el: HTMLInputElement) { + const isChecked = $el.checked; + + this.sendCommand(ReorderCommandType, { + ascending: isChecked, + }); + } + protected override onMessageReceived(e: MessageEvent) { const msg = e.data; @@ -342,20 +366,36 @@ class RebaseEditor extends App { let squashToHere = false; let tabIndex = 0; - for (const entry of state.entries) { - squashToHere = false; - if (entry.action === 'squash' || entry.action === 'fixup') { - squashing = true; - } else if (squashing) { - if (entry.action !== 'drop') { - squashToHere = true; - squashing = false; + const $entries = document.createDocumentFragment(); + const appendEntries = () => { + const appendEntry = (entry: RebaseEntry) => { + squashToHere = false; + if (entry.action === 'squash' || entry.action === 'fixup') { + squashing = true; + } else if (squashing) { + if (entry.action !== 'drop') { + squashToHere = true; + squashing = false; + } } + + let $el: HTMLLIElement; + [$el, tabIndex] = this.createEntry(entry, state, ++tabIndex, squashToHere); + + return $el; + }; + + const entryList = state.entries.map(appendEntry); + if (state.ascending) { + entryList.reverse().forEach($el => $entries.appendChild($el)); + } else { + entryList.forEach($el => $entries.appendChild($el)); } + }; - let $el: HTMLLIElement; - [$el, tabIndex] = this.createEntry(entry, state, ++tabIndex, squashToHere); - $container.appendChild($el); + if (!state.ascending) { + $container.classList.remove('entries--ascending'); + appendEntries(); } if (state.onto) { @@ -372,11 +412,23 @@ class RebaseEditor extends App { ++tabIndex, false, ); - $container.appendChild($el); + $entries.appendChild($el); $container.classList.add('entries--base'); } } + if (state.ascending) { + $container.classList.add('entries--ascending'); + appendEntries(); + } + + const $checkbox = document.getElementById('ordering'); + if ($checkbox != null) { + ($checkbox as HTMLInputElement).checked = state.ascending; + } + + $container.appendChild($entries); + document .querySelectorAll( `${focusSelect ? 'select' : 'li'}[data-ref="${focusRef ?? state.entries[0].ref}"]`, @@ -398,7 +450,7 @@ class RebaseEditor extends App { $entry.dataset.ref = entry.ref; if (entry.action != null) { - $entry.tabIndex = tabIndex++; + $entry.tabIndex = 0; const $dragHandle = document.createElement('span'); $dragHandle.classList.add('entry-handle'); @@ -411,8 +463,8 @@ class RebaseEditor extends App { const $select = document.createElement('select'); $select.dataset.ref = entry.ref; $select.name = 'action'; - $select.tabIndex = tabIndex++; + const $options = document.createDocumentFragment(); for (const action of rebaseActions) { const $option = document.createElement('option'); $option.value = action; @@ -422,8 +474,9 @@ class RebaseEditor extends App { $option.selected = true; } - $select.appendChild($option); + $options.appendChild($option); } + $select.appendChild($options); $selectContainer.appendChild($select); } @@ -465,7 +518,6 @@ class RebaseEditor extends App { // $ref.dataset.prev = prev ? `${prev} \u2190 ` : ''; $ref.href = commit?.ref ? state.commands.commit.replace(this.commitTokenRegex, commit.ref) : '#'; $ref.textContent = entry.ref.substr(0, 7); - $ref.tabIndex = tabIndex++; $entry.appendChild($ref); return [$entry, tabIndex]; diff --git a/src/webviews/rebase/protocol.ts b/src/webviews/rebase/protocol.ts index 2fcb57c..4e2f4e2 100644 --- a/src/webviews/rebase/protocol.ts +++ b/src/webviews/rebase/protocol.ts @@ -10,6 +10,8 @@ export interface State { commands: { commit: string; }; + + ascending: boolean; } export interface RebaseEntry { @@ -48,6 +50,11 @@ export const StartCommandType = new IpcCommandType('rebase/start'); export const SwitchCommandType = new IpcCommandType('rebase/switch'); +export interface ReorderParams { + ascending: boolean; +} +export const ReorderCommandType = new IpcCommandType('rebase/reorder'); + export interface ChangeEntryParams { ref: string; action: RebaseEntryAction; diff --git a/src/webviews/rebase/rebaseEditor.ts b/src/webviews/rebase/rebaseEditor.ts index 4b7a5ba..125bf68 100644 --- a/src/webviews/rebase/rebaseEditor.ts +++ b/src/webviews/rebase/rebaseEditor.ts @@ -36,6 +36,8 @@ import { MoveEntryCommandType, RebaseEntry, RebaseEntryAction, + ReorderCommandType, + ReorderParams, StartCommandType, State, SwitchCommandType, @@ -98,6 +100,7 @@ interface RebaseEditorContext { export class RebaseEditorProvider implements CustomTextEditorProvider, Disposable { private readonly _disposable: Disposable; + private ascending = false; constructor(private readonly container: Container) { this._disposable = Disposable.from( @@ -108,6 +111,7 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl }, }), ); + this.ascending = configuration.get('rebaseEditor.ordering') === 'asc'; } dispose() { @@ -245,7 +249,13 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl private async parseState(context: RebaseEditorContext): Promise { const branch = await this.container.git.getBranch(context.repoPath); - const state = await parseRebaseTodo(this.container, context.document.getText(), context.repoPath, branch?.name); + const state = await parseRebaseTodo( + this.container, + context.document.getText(), + context.repoPath, + branch?.name, + this.ascending, + ); return state; } @@ -288,6 +298,12 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl onIpc(SwitchCommandType, e, () => this.switch(context)); break; + case ReorderCommandType.method: + onIpc(ReorderCommandType, e, params => { + this.reorder(params, context); + }); + break; + case ChangeEntryCommandType.method: onIpc(ChangeEntryCommandType, e, async params => { const entries = parseRebaseTodoEntries(context.document); @@ -469,6 +485,12 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl }); } + private reorder(params: ReorderParams, context: RebaseEditorContext) { + this.ascending = params.ascending ?? false; + void configuration.updateEffective('rebaseEditor.ordering', this.ascending ? 'asc' : 'desc'); + void this.getStateAndNotify(context); + } + private async getHtml(context: RebaseEditorContext): Promise { const webRootUri = Uri.joinPath(this.container.context.extensionUri, 'dist', 'webviews'); const uri = Uri.joinPath(webRootUri, 'rebase.html'); @@ -516,6 +538,7 @@ async function parseRebaseTodo( contents: string | { entries: RebaseEntry[]; onto: string }, repoPath: string, branch: string | undefined, + ascending: boolean, ): Promise> { let onto: string; let entries; @@ -598,6 +621,7 @@ async function parseRebaseTodo( commands: { commit: ShowQuickCommitCommand.getMarkdownCommandArgs(`\${commit}`, repoPath), }, + ascending: ascending, }; }