Browse Source

Fixes #2141, #1732, #1652 improves rebase editor

- Improves performance and user experience
 - Adds always available header and footer
 - Shows commit details on selection
 - Adds full commit message
 - Adds  fixup shortcut key to UI
 - Fixes large rebases not showing commit details
main
Eric Amodio 2 years ago
parent
commit
528e1e3a79
10 changed files with 527 additions and 412 deletions
  1. +9
    -0
      CHANGELOG.md
  2. +6
    -4
      src/env/node/git/git.ts
  3. +10
    -3
      src/env/node/git/localGitProvider.ts
  4. +8
    -1
      src/git/gitProviderService.ts
  5. +32
    -27
      src/webviews/apps/rebase/rebase.html
  6. +50
    -13
      src/webviews/apps/rebase/rebase.scss
  7. +85
    -87
      src/webviews/apps/rebase/rebase.ts
  8. +6
    -6
      src/webviews/apps/shared/dom.ts
  9. +13
    -7
      src/webviews/rebase/protocol.ts
  10. +308
    -264
      src/webviews/rebase/rebaseEditor.ts

+ 9
- 0
CHANGELOG.md View File

@ -8,10 +8,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
### Changed ### Changed
- Improved Rebase editor — better performance and user experience
- Changes the header and footer to always be visible
- Shows the _Commit Details_ view on commit selection
- Adds full (multiline) commit message
- Adds the `f` fixup shortcut key to UI
- Ensures that large rebases show rich commit details
- Changes the _Home_ view to always be available - Changes the _Home_ view to always be available
### Fixed ### Fixed
- Fixes [#2141](https://github.com/gitkraken/vscode-gitlens/issues/2141) - GitLens's rebase UI randomly fails loading interactive rebase when performed outside of VSC
- Fixes [#1732](https://github.com/gitkraken/vscode-gitlens/issues/1732) - Phantom rebase-merge directory (`rm -rf ".git/rebase-merge"`)
- Fixes [#1652](https://github.com/gitkraken/vscode-gitlens/issues/1652) - Closing interacteractive rebase editor after "git rebase --edit" aborts rebase-in-progress
- Fixes [#1549](https://github.com/gitkraken/vscode-gitlens/issues/1549) - Fetch does not work when local branch name differs from remote branch name - Fixes [#1549](https://github.com/gitkraken/vscode-gitlens/issues/1549) - Fetch does not work when local branch name differs from remote branch name
- Fixes [#2292](https://github.com/gitkraken/vscode-gitlens/issues/2292) - Push button in BranchTrackingStatusNode of non-current branch does not trigger "Push force" - Fixes [#2292](https://github.com/gitkraken/vscode-gitlens/issues/2292) - Push button in BranchTrackingStatusNode of non-current branch does not trigger "Push force"
- Fixes [#1488](https://github.com/gitkraken/vscode-gitlens/issues/1488) - Open Folder History not working with non-English language pack - Fixes [#1488](https://github.com/gitkraken/vscode-gitlens/issues/1488) - Open Folder History not working with non-English language pack

+ 6
- 4
src/env/node/git/git.ts View File

@ -15,6 +15,7 @@ import { GitLogParser } from '../../../git/parsers/logParser';
import { GitReflogParser } from '../../../git/parsers/reflogParser'; import { GitReflogParser } from '../../../git/parsers/reflogParser';
import { GitTagParser } from '../../../git/parsers/tagParser'; import { GitTagParser } from '../../../git/parsers/tagParser';
import { Logger } from '../../../logger'; import { Logger } from '../../../logger';
import { join } from '../../../system/iterable';
import { dirname, isAbsolute, isFolderGlob, joinPaths, normalizePath, splitPath } from '../../../system/path'; import { dirname, isAbsolute, isFolderGlob, joinPaths, normalizePath, splitPath } from '../../../system/path';
import { getDurationMilliseconds } from '../../../system/string'; import { getDurationMilliseconds } from '../../../system/string';
import { compare, fromString } from '../../../system/version'; import { compare, fromString } from '../../../system/version';
@ -1191,17 +1192,18 @@ export class Git {
limit?: number; limit?: number;
ordering?: 'date' | 'author-date' | 'topo' | null; ordering?: 'date' | 'author-date' | 'topo' | null;
skip?: number; skip?: number;
useShow?: boolean;
shas?: Set<string>;
}, },
) { ) {
if (options?.useShow) {
if (options?.shas != null) {
const stdin = join(options.shas, '\n');
return this.git<string>( return this.git<string>(
{ cwd: repoPath },
{ cwd: repoPath, stdin: stdin },
'show', 'show',
'--stdin',
'--name-status', '--name-status',
`--format=${GitLogParser.defaultFormat}`, `--format=${GitLogParser.defaultFormat}`,
'--use-mailmap', '--use-mailmap',
...search,
); );
} }

+ 10
- 3
src/env/node/git/localGitProvider.ts View File

@ -4231,7 +4231,14 @@ export class LocalGitProvider implements GitProvider, Disposable {
return cancelled ? ref : resolved ?? ref; return cancelled ? ref : resolved ?? ref;
} }
@log()
@log<LocalGitProvider['richSearchCommits']>({
args: {
1: s =>
`[${s.matchAll ? 'A' : ''}${s.matchCase ? 'C' : ''}${s.matchRegex ? 'R' : ''}]: ${
s.query.length > 500 ? `${s.query.substring(0, 500)}...` : s.query
}`,
},
})
async richSearchCommits( async richSearchCommits(
repoPath: string, repoPath: string,
search: SearchQuery, search: SearchQuery,
@ -4252,11 +4259,11 @@ export class LocalGitProvider implements GitProvider, Disposable {
args.push(...files); args.push(...files);
} }
const data = await this.git.log__search(repoPath, args, {
const data = await this.git.log__search(repoPath, shas?.size ? undefined : args, {
ordering: configuration.get('advanced.commitOrdering'), ordering: configuration.get('advanced.commitOrdering'),
...options, ...options,
limit: limit, limit: limit,
useShow: Boolean(shas?.size),
shas: shas,
}); });
const log = GitLogParser.parse( const log = GitLogParser.parse(
this.container, this.container,

+ 8
- 1
src/git/gitProviderService.ts View File

@ -2232,7 +2232,14 @@ export class GitProviderService implements Disposable {
return provider.resolveReference(path, ref, pathOrUri, options); return provider.resolveReference(path, ref, pathOrUri, options);
} }
@log()
@log<GitProviderService['richSearchCommits']>({
args: {
1: s =>
`[${s.matchAll ? 'A' : ''}${s.matchCase ? 'C' : ''}${s.matchRegex ? 'R' : ''}]: ${
s.query.length > 500 ? `${s.query.substring(0, 500)}...` : s.query
}`,
},
})
async richSearchCommits( async richSearchCommits(
repoPath: string | Uri, repoPath: string | Uri,
search: SearchQuery, search: SearchQuery,

+ 32
- 27
src/webviews/apps/rebase/rebase.html View File

@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
</head> </head>
<body class="preload">
<body class="scrollable preload">
<div class="container"> <div class="container">
<header> <header>
<h2>GitLens Interactive Rebase</h2> <h2>GitLens Interactive Rebase</h2>
@ -25,35 +25,40 @@
<!-- should have an <div aria-live="polite" /> to notify order change --> <!-- should have an <div aria-live="polite" /> to notify order change -->
</div> </div>
</header> </header>
<ul id="entries" class="entries"></ul>
<div class="shortcuts">
<span class="shortcut"><kbd>p</kbd><span>Pick</span></span>
<span class="shortcut"><kbd>r</kbd><span>Reword</span></span>
<span class="shortcut"><kbd>e</kbd><span>Edit</span></span>
<span class="shortcut"><kbd>s</kbd><span>Squash</span></span>
<span class="shortcut"><kbd>d</kbd><span>Drop</span></span>
<span class="shortcut"><kbd>alt ↑</kbd><span>Move Up</span></span>
<span class="shortcut"><kbd>alt ↓</kbd><span>Move Down</span></span>
<span class="shortcut"><kbd>/</kbd><span>Search</span></span>
<div class="entries-container scrollable">
<ul id="entries" class="entries scrollable"></ul>
</div> </div>
<div class="actions">
<div class="actions--left">
<button name="disable" class="button button--flat-subtle" data-action="disable" tabindex="-1">
Disable Rebase Editor<span class="shortcut">Will Abort Rebase</span>
</button>
<button name="switch" class="button button--flat-subtle" data-action="switch" tabindex="-1">
Switch to Text
</button>
<footer>
<div class="shortcuts">
<span class="shortcut"><kbd>p</kbd><span>Pick</span></span>
<span class="shortcut"><kbd>r</kbd><span>Reword</span></span>
<span class="shortcut"><kbd>e</kbd><span>Edit</span></span>
<span class="shortcut"><kbd>s</kbd><span>Squash</span></span>
<span class="shortcut"><kbd>f</kbd><span>Fixup</span></span>
<span class="shortcut"><kbd>d</kbd><span>Drop</span></span>
<span class="shortcut"><kbd>alt ↑</kbd><span>Move&nbsp;Up</span></span>
<span class="shortcut"><kbd>alt ↓</kbd><span>Move&nbsp;Down</span></span>
<span class="shortcut"><kbd>/</kbd><span>Search</span></span>
</div> </div>
<div class="actions--right">
<button name="start" class="button button--flat-primary" data-action="start">
Start Rebase <span class="shortcut">Ctrl+Enter</span>
</button>
<button name="abort" class="button button--flat-secondary" data-action="abort">
Abort <span class="shortcut">Ctrl+A</span>
</button>
<div class="actions">
<div class="actions--left">
<button name="disable" class="button button--flat-subtle" data-action="disable" tabindex="-1">
Disable Rebase Editor<span class="shortcut">Will Abort Rebase</span>
</button>
<button name="switch" class="button button--flat-subtle" data-action="switch" tabindex="-1">
Switch to Text
</button>
</div>
<div class="actions--right">
<button name="start" class="button button--flat-primary" data-action="start">
Start Rebase <span class="shortcut">Ctrl+Enter</span>
</button>
<button name="abort" class="button button--flat-secondary" data-action="abort">
Abort <span class="shortcut">Ctrl+A</span>
</button>
</div>
</div> </div>
</div>
</footer>
</div> </div>
#{endOfBody} #{endOfBody}
<style nonce="#{cspNonce}"> <style nonce="#{cspNonce}">

+ 50
- 13
src/webviews/apps/rebase/rebase.scss View File

@ -3,25 +3,30 @@
@import '../shared/utils'; @import '../shared/utils';
body { body {
height: unset;
overflow: overlay;
} }
.container { .container {
display: grid; display: grid;
font-size: 1.3em; font-size: 1.3em;
grid-template-areas: 'header' 'entries' 'shortcuts' 'actions';
grid-template-columns: repeat(1, 1fr min-content);
grid-template-areas: 'header' 'entries' 'footer';
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
height: 100vh;
margin: 0 auto; margin: 0 auto;
grid-gap: 0.5em 0;
max-width: 1200px; max-width: 1200px;
min-width: 450px;
min-width: 495px;
} }
header { header {
grid-area: header;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
flex-wrap: wrap; flex-wrap: wrap;
margin: 0; margin: 0;
gap: 0 0.5em;
position: relative;
h2 { h2 {
flex: auto 0 1; flex: auto 0 1;
@ -57,8 +62,24 @@ h4 {
opacity: 0.8; opacity: 0.8;
} }
.entries {
h4#subhead {
padding-right: 12rem;
}
footer {
grid-area: footer;
display: grid;
grid-template-areas: 'shortcuts' 'actions';
border-top: 1px solid var(--vscode-sideBarSectionHeader-border);
}
.entries-container {
grid-area: entries; grid-area: entries;
overflow: auto;
}
.entries {
border-left: 2px solid; border-left: 2px solid;
margin-left: 10px; margin-left: 10px;
padding-left: 4px; padding-left: 4px;
@ -82,23 +103,37 @@ h4 {
.shortcuts { .shortcuts {
grid-area: shortcuts; grid-area: shortcuts;
margin: 0 auto;
display: flex;
justify-content: center;
text-align: center;
} }
.actions { .actions {
grid-area: actions; grid-area: actions;
margin: 10px;
margin: 0.5rem 0 1rem 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
} }
.actions--left { .actions--left {
display: flex; display: flex;
gap: 0 1rem;
} }
.actions--right { .actions--right {
display: flex; display: flex;
margin-left: auto; margin-left: auto;
gap: 0 1rem;
}
.button {
letter-spacing: 0.1rem;
margin: 0;
padding: 0.5rem 1rem;
.shortcut {
margin: 0.1rem 0 0 0;
}
} }
$entry-padding: 5px; $entry-padding: 5px;
@ -107,7 +142,7 @@ $entry-padding: 5px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin: 0 10px;
margin: 0px 5px 0px 10px;
padding: $entry-padding 0; padding: $entry-padding 0;
border: 2px solid transparent; border: 2px solid transparent;
border-radius: 3px; border-radius: 3px;
@ -222,7 +257,7 @@ $entry-padding: 5px;
opacity: 0.3; opacity: 0.3;
} }
& > .entry-ref {
& > .entry-sha {
opacity: 0.4; opacity: 0.4;
} }
@ -349,7 +384,6 @@ $entry-padding: 5px;
.entry-message { .entry-message {
flex: 100% 1 1; flex: 100% 1 1;
margin: 0 10px; margin: 0 10px;
position: relative;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -377,7 +411,7 @@ $entry-padding: 5px;
.entry-author, .entry-author,
.entry-date, .entry-date,
.entry-ref {
.entry-sha {
flex: auto 0 0; flex: auto 0 0;
margin: 0 10px; margin: 0 10px;
opacity: 0.5; opacity: 0.5;
@ -394,7 +428,7 @@ $entry-padding: 5px;
} }
} }
.entry-ref {
.entry-sha {
opacity: 0.7; opacity: 0.7;
} }
@ -413,6 +447,9 @@ $entry-padding: 5px;
flex-direction: row-reverse; flex-direction: row-reverse;
align-items: center; align-items: center;
gap: 0.5em; gap: 0.5em;
position: absolute;
top: 2rem;
right: 0;
&__input, &__input,
&__indicator { &__indicator {

+ 85
- 87
src/webviews/apps/rebase/rebase.ts View File

@ -13,6 +13,7 @@ import {
SearchCommandType, SearchCommandType,
StartCommandType, StartCommandType,
SwitchCommandType, SwitchCommandType,
UpdateSelectionCommandType,
} from '../../rebase/protocol'; } from '../../rebase/protocol';
import { App } from '../shared/appBase'; import { App } from '../shared/appBase';
import { DOM } from '../shared/dom'; import { DOM } from '../shared/dom';
@ -61,7 +62,7 @@ class RebaseEditor extends App {
let squashing = false; let squashing = false;
let squashToHere = false; let squashToHere = false;
const $entries = [...document.querySelectorAll<HTMLLIElement>('li[data-ref]')];
const $entries = [...document.querySelectorAll<HTMLLIElement>('li[data-sha]')];
if (this.state.ascending) { if (this.state.ascending) {
$entries.reverse(); $entries.reverse();
} }
@ -87,15 +88,15 @@ class RebaseEditor extends App {
return; return;
} }
const ref = e.item.dataset.ref;
if (ref != null) {
const sha = e.item.dataset.sha;
if (sha != null) {
let indexTarget = e.newIndex; let indexTarget = e.newIndex;
if (this.state.ascending && e.oldIndex) { if (this.state.ascending && e.oldIndex) {
indexTarget = this.getEntryIndex(ref) + (indexTarget - e.oldIndex) * -1;
indexTarget = this.getEntryIndex(sha) + (indexTarget - e.oldIndex) * -1;
} }
this.moveEntry(ref, indexTarget, false);
this.moveEntry(sha, indexTarget, false);
document.querySelectorAll<HTMLLIElement>(`li[data-ref="${ref}"]`)[0]?.focus();
this.setSelectedEntry(sha);
} }
}, },
onMove: e => !e.related.classList.contains('entry--base'), onMove: e => !e.related.classList.contains('entry--base'),
@ -134,8 +135,8 @@ class RebaseEditor extends App {
DOM.on('[data-action="abort"]', 'click', () => this.onAbortClicked()), DOM.on('[data-action="abort"]', 'click', () => this.onAbortClicked()),
DOM.on('[data-action="disable"]', 'click', () => this.onDisableClicked()), DOM.on('[data-action="disable"]', 'click', () => this.onDisableClicked()),
DOM.on('[data-action="switch"]', 'click', () => this.onSwitchClicked()), DOM.on('[data-action="switch"]', 'click', () => this.onSwitchClicked()),
DOM.on('li[data-ref]', 'keydown', (e, target: HTMLElement) => {
if (target.matches('select[data-ref]')) {
DOM.on('li[data-sha]', 'keydown', (e, target: HTMLLIElement) => {
if (e.target?.matches('select[data-sha]')) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
target.focus(); target.focus();
} }
@ -144,11 +145,11 @@ class RebaseEditor extends App {
} }
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
if (e.key === 'Enter' && target.matches('a.entry-ref')) {
if (e.key === 'Enter' && e.target?.matches('a.entry-sha')) {
return; return;
} }
const $select = target.querySelectorAll<HTMLSelectElement>('select[data-ref]')[0];
const $select = target.querySelectorAll<HTMLSelectElement>('select[data-sha]')[0];
if ($select != null) { if ($select != null) {
$select.focus(); $select.focus();
} }
@ -160,59 +161,59 @@ class RebaseEditor extends App {
? 1 ? 1
: -1; : -1;
if (e.altKey) { if (e.altKey) {
const ref = target.dataset.ref;
if (ref) {
const sha = target.dataset.sha;
if (sha) {
e.stopPropagation(); e.stopPropagation();
this.moveEntry(ref, advance, true);
this.moveEntry(sha, advance, true);
} }
} else { } else {
if (this.state == null) return; if (this.state == null) return;
let ref = target.dataset.ref;
if (ref == null) return;
let sha = target.dataset.sha;
if (sha == null) return;
e.preventDefault(); e.preventDefault();
let index = this.getEntryIndex(ref) + advance;
let index = this.getEntryIndex(sha) + advance;
if (index < 0) { if (index < 0) {
index = this.state.entries.length - 1; index = this.state.entries.length - 1;
} else if (index === this.state.entries.length) { } else if (index === this.state.entries.length) {
index = 0; index = 0;
} }
ref = this.state.entries[index].ref;
document.querySelectorAll<HTMLLIElement>(`li[data-ref="${ref}"]`)[0]?.focus();
sha = this.state.entries[index].sha;
this.setSelectedEntry(sha);
} }
} }
} else if (e.key === 'j' || e.key === 'k') { } else if (e.key === 'j' || e.key === 'k') {
if (!e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) { if (!e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
if (this.state == null) return; if (this.state == null) return;
let ref = target.dataset.ref;
if (ref == null) return;
let sha = target.dataset.sha;
if (sha == null) return;
e.preventDefault(); e.preventDefault();
const shouldAdvance = this.state.ascending ? e.key === 'k' : e.key === 'j'; const shouldAdvance = this.state.ascending ? e.key === 'k' : e.key === 'j';
let index = this.getEntryIndex(ref) + (shouldAdvance ? 1 : -1);
let index = this.getEntryIndex(sha) + (shouldAdvance ? 1 : -1);
if (index < 0) { if (index < 0) {
index = this.state.entries.length - 1; index = this.state.entries.length - 1;
} else if (index === this.state.entries.length) { } else if (index === this.state.entries.length) {
index = 0; index = 0;
} }
ref = this.state.entries[index].ref;
document.querySelectorAll<HTMLLIElement>(`li[data-ref="${ref}"]`)[0]?.focus();
sha = this.state.entries[index].sha;
this.setSelectedEntry(sha);
} }
} else if (e.key === 'J' || e.key === 'K') { } else if (e.key === 'J' || e.key === 'K') {
if (!e.metaKey && !e.ctrlKey && !e.altKey && e.shiftKey) { if (!e.metaKey && !e.ctrlKey && !e.altKey && e.shiftKey) {
const ref = target.dataset.ref;
if (ref) {
const sha = target.dataset.sha;
if (sha) {
e.stopPropagation(); e.stopPropagation();
const shouldAdvance = this.state.ascending ? e.key === 'K' : e.key === 'J'; const shouldAdvance = this.state.ascending ? e.key === 'K' : e.key === 'J';
this.moveEntry(ref, shouldAdvance ? 1 : -1, true);
this.moveEntry(sha, shouldAdvance ? 1 : -1, true);
} }
} }
} else if (!e.metaKey && !e.altKey && !e.ctrlKey) { } else if (!e.metaKey && !e.altKey && !e.ctrlKey) {
@ -220,7 +221,7 @@ class RebaseEditor extends App {
if (action !== undefined) { if (action !== undefined) {
e.stopPropagation(); e.stopPropagation();
const $select = target.querySelectorAll<HTMLSelectElement>('select[data-ref]')[0];
const $select = target.querySelectorAll<HTMLSelectElement>('select[data-sha]')[0];
if ($select != null && !$select.disabled) { if ($select != null && !$select.disabled) {
$select.value = action; $select.value = action;
this.onSelectChanged($select); this.onSelectChanged($select);
@ -228,7 +229,8 @@ class RebaseEditor extends App {
} }
} }
}), }),
DOM.on('select[data-ref]', 'input', (e, target: HTMLSelectElement) => this.onSelectChanged(target)),
DOM.on('li[data-sha]', 'focus', (e, target: HTMLLIElement) => this.onSelectionChanged(target.dataset.sha)),
DOM.on('select[data-sha]', 'input', (e, target: HTMLSelectElement) => this.onSelectChanged(target)),
DOM.on('input[data-action="reorder"]', 'input', (e, target: HTMLInputElement) => DOM.on('input[data-action="reorder"]', 'input', (e, target: HTMLInputElement) =>
this.onOrderChanged(target), this.onOrderChanged(target),
), ),
@ -237,32 +239,32 @@ class RebaseEditor extends App {
return disposables; return disposables;
} }
private getEntry(ref: string) {
return this.state?.entries.find(e => e.ref === ref);
private getEntry(sha: string) {
return this.state?.entries.find(e => e.sha === sha);
} }
private getEntryIndex(ref: string) {
return this.state?.entries.findIndex(e => e.ref === ref) ?? -1;
private getEntryIndex(sha: string) {
return this.state?.entries.findIndex(e => e.sha === sha) ?? -1;
} }
private moveEntry(ref: string, index: number, relative: boolean) {
const entry = this.getEntry(ref);
private moveEntry(sha: string, index: number, relative: boolean) {
const entry = this.getEntry(sha);
if (entry != null) { if (entry != null) {
this.sendCommand(MoveEntryCommandType, { this.sendCommand(MoveEntryCommandType, {
ref: entry.ref,
sha: entry.sha,
to: index, to: index,
relative: relative, relative: relative,
}); });
} }
} }
private setEntryAction(ref: string, action: RebaseEntryAction) {
const entry = this.getEntry(ref);
private setEntryAction(sha: string, action: RebaseEntryAction) {
const entry = this.getEntry(sha);
if (entry != null) { if (entry != null) {
if (entry.action === action) return; if (entry.action === action) return;
this.sendCommand(ChangeEntryCommandType, { this.sendCommand(ChangeEntryCommandType, {
ref: entry.ref,
sha: entry.sha,
action: action, action: action,
}); });
} }
@ -281,9 +283,9 @@ class RebaseEditor extends App {
} }
private onSelectChanged($el: HTMLSelectElement) { private onSelectChanged($el: HTMLSelectElement) {
const ref = $el.dataset.ref;
if (ref) {
this.setEntryAction(ref, $el.options[$el.selectedIndex].value as RebaseEntryAction);
const sha = $el.dataset.sha;
if (sha) {
this.setEntryAction(sha, $el.options[$el.selectedIndex].value as RebaseEntryAction);
} }
} }
@ -298,9 +300,17 @@ class RebaseEditor extends App {
private onOrderChanged($el: HTMLInputElement) { private onOrderChanged($el: HTMLInputElement) {
const isChecked = $el.checked; const isChecked = $el.checked;
this.sendCommand(ReorderCommandType, {
ascending: isChecked,
});
this.sendCommand(ReorderCommandType, { ascending: isChecked });
}
private onSelectionChanged(sha: string | undefined) {
if (sha == null) return;
this.sendCommand(UpdateSelectionCommandType, { sha: sha });
}
private setSelectedEntry(sha: string, focusSelect: boolean = false) {
document.querySelectorAll<HTMLLIElement>(`${focusSelect ? 'select' : 'li'}[data-sha="${sha}"]`)[0]?.focus();
} }
protected override onMessageReceived(e: MessageEvent) { protected override onMessageReceived(e: MessageEvent) {
@ -311,7 +321,7 @@ class RebaseEditor extends App {
this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`); this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`);
onIpc(DidChangeNotificationType, msg, params => { onIpc(DidChangeNotificationType, msg, params => {
this.setState({ ...this.state, ...params.state });
this.setState(params.state);
this.refresh(this.state); this.refresh(this.state);
}); });
break; break;
@ -322,9 +332,9 @@ class RebaseEditor extends App {
} }
private refresh(state: State) { private refresh(state: State) {
const focusRef = document.activeElement?.closest<HTMLLIElement>('li[data-ref]')?.dataset.ref;
const focusRef = document.activeElement?.closest<HTMLLIElement>('li[data-sha]')?.dataset.sha;
let focusSelect = false; let focusSelect = false;
if (document.activeElement?.matches('select[data-ref]')) {
if (document.activeElement?.matches('select[data-sha]')) {
focusSelect = true; focusSelect = true;
} }
@ -343,9 +353,9 @@ class RebaseEditor extends App {
); );
$subhead.appendChild($el); $subhead.appendChild($el);
if (state.onto) {
if (state.onto != null) {
$el = document.createElement('span'); $el = document.createElement('span');
$el.textContent = state.onto;
$el.textContent = state.onto.sha;
$el.classList.add('icon--commit'); $el.classList.add('icon--commit');
$subhead.appendChild($el); $subhead.appendChild($el);
} }
@ -377,8 +387,10 @@ class RebaseEditor extends App {
let tabIndex = 0; let tabIndex = 0;
const $entries = document.createDocumentFragment(); const $entries = document.createDocumentFragment();
const appendEntries = () => {
const appendEntry = (entry: RebaseEntry) => {
function appendEntries(this: RebaseEditor) {
const entries = state.ascending ? state.entries.slice().reverse() : state.entries;
let $el: HTMLLIElement;
for (const entry of entries) {
squashToHere = false; squashToHere = false;
if (entry.action === 'squash' || entry.action === 'fixup') { if (entry.action === 'squash' || entry.action === 'fixup') {
squashing = true; squashing = true;
@ -389,34 +401,26 @@ class RebaseEditor extends App {
} }
} }
let $el: HTMLLIElement;
[$el, tabIndex] = this.createEntry(entry, state, ++tabIndex, squashToHere); [$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));
$entries.appendChild($el);
} }
};
}
if (!state.ascending) { if (!state.ascending) {
$container.classList.remove('entries--ascending'); $container.classList.remove('entries--ascending');
appendEntries();
appendEntries.call(this);
} }
if (state.onto) {
const commit = state.commits.find(c => c.ref.startsWith(state.onto));
if (state.onto != null) {
const commit = state.onto.commit;
if (commit != null) { if (commit != null) {
const [$el] = this.createEntry( const [$el] = this.createEntry(
{ {
action: undefined!, action: undefined!,
index: 0, index: 0,
message: commit.message.split('\n')[0],
ref: state.onto,
message: commit.message,
sha: state.onto.sha,
}, },
state, state,
++tabIndex, ++tabIndex,
@ -429,7 +433,7 @@ class RebaseEditor extends App {
if (state.ascending) { if (state.ascending) {
$container.classList.add('entries--ascending'); $container.classList.add('entries--ascending');
appendEntries();
appendEntries.call(this);
} }
const $checkbox = document.getElementById('ordering'); const $checkbox = document.getElementById('ordering');
@ -439,13 +443,7 @@ class RebaseEditor extends App {
$container.appendChild($entries); $container.appendChild($entries);
document
.querySelectorAll<HTMLLIElement>(
`${focusSelect ? 'select' : 'li'}[data-ref="${focusRef ?? state.entries[0].ref}"]`,
)[0]
?.focus();
this.bind();
this.setSelectedEntry(focusRef ?? state.entries[0].sha, focusSelect);
} }
private createEntry( private createEntry(
@ -457,7 +455,7 @@ class RebaseEditor extends App {
const $entry = document.createElement('li'); const $entry = document.createElement('li');
$entry.classList.add('entry', `entry--${entry.action ?? 'base'}`); $entry.classList.add('entry', `entry--${entry.action ?? 'base'}`);
$entry.classList.toggle('entry--squash-to', squashToHere); $entry.classList.toggle('entry--squash-to', squashToHere);
$entry.dataset.ref = entry.ref;
$entry.dataset.sha = entry.sha;
if (entry.action != null) { if (entry.action != null) {
$entry.tabIndex = 0; $entry.tabIndex = 0;
@ -471,7 +469,7 @@ class RebaseEditor extends App {
$entry.appendChild($selectContainer); $entry.appendChild($selectContainer);
const $select = document.createElement('select'); const $select = document.createElement('select');
$select.dataset.ref = entry.ref;
$select.dataset.sha = entry.sha;
$select.name = 'action'; $select.name = 'action';
const $options = document.createDocumentFragment(); const $options = document.createDocumentFragment();
@ -490,17 +488,18 @@ class RebaseEditor extends App {
$selectContainer.appendChild($select); $selectContainer.appendChild($select);
} }
const commit = entry.commit;
const $message = document.createElement('span'); const $message = document.createElement('span');
$message.classList.add('entry-message'); $message.classList.add('entry-message');
$message.textContent = entry.message ?? '';
const message = commit?.message.trim() ?? entry.message.trim();
$message.textContent = message.replace(/\n+(?:\s+\n+)?/g, ' | ');
$message.title = message;
$entry.appendChild($message); $entry.appendChild($message);
const commit = state.commits.find(c => c.ref.startsWith(entry.ref));
if (commit != null) { if (commit != null) {
$message.title = commit.message ?? '';
if (commit.author) { if (commit.author) {
const author = state.authors.find(a => a.author === commit.author);
const author = state.authors[commit.author];
if (author?.avatarUrl.length) { if (author?.avatarUrl.length) {
const $avatar = document.createElement('img'); const $avatar = document.createElement('img');
$avatar.classList.add('entry-avatar'); $avatar.classList.add('entry-avatar');
@ -523,12 +522,11 @@ class RebaseEditor extends App {
} }
} }
const $ref = document.createElement('a');
$ref.classList.add('entry-ref', 'icon--commit');
// $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);
$entry.appendChild($ref);
const $sha = document.createElement('a');
$sha.classList.add('entry-sha', 'icon--commit');
$sha.href = state.commands.commit.replace(this.commitTokenRegex, commit?.sha ?? entry.sha);
$sha.textContent = entry.sha.substr(0, 7);
$entry.appendChild($sha);
return [$entry, tabIndex]; return [$entry, tabIndex];
} }

+ 6
- 6
src/webviews/apps/shared/dom.ts View File

@ -20,19 +20,19 @@ export namespace DOM {
export function on<T extends HTMLElement, K extends keyof DocumentEventMap>( export function on<T extends HTMLElement, K extends keyof DocumentEventMap>(
element: T, element: T,
name: K, name: K,
listener: (e: DocumentEventMap[K], target: T) => void,
listener: (e: DocumentEventMap[K] & { target: HTMLElement | null }, target: T) => void,
options?: boolean | AddEventListenerOptions, options?: boolean | AddEventListenerOptions,
): Disposable; ): Disposable;
export function on<T extends Element, K extends keyof DocumentEventMap>( export function on<T extends Element, K extends keyof DocumentEventMap>(
selector: string, selector: string,
name: K, name: K,
listener: (e: DocumentEventMap[K], target: T) => void,
listener: (e: DocumentEventMap[K] & { target: HTMLElement | null }, target: T) => void,
options?: boolean | AddEventListenerOptions, options?: boolean | AddEventListenerOptions,
): Disposable; ): Disposable;
export function on<T extends HTMLElement, K>( export function on<T extends HTMLElement, K>(
selector: string, selector: string,
name: string, name: string,
listener: (e: CustomEvent<K>, target: T) => void,
listener: (e: CustomEvent<K> & { target: HTMLElement | null }, target: T) => void,
options?: boolean | AddEventListenerOptions, options?: boolean | AddEventListenerOptions,
): Disposable; ): Disposable;
export function on<K extends keyof (DocumentEventMap | WindowEventMap), T extends Document | Element | Window>( export function on<K extends keyof (DocumentEventMap | WindowEventMap), T extends Document | Element | Window>(
@ -45,10 +45,10 @@ export namespace DOM {
if (typeof sourceOrSelector === 'string') { if (typeof sourceOrSelector === 'string') {
const filteredListener = function (this: T, e: (DocumentEventMap | WindowEventMap)[K]) { const filteredListener = function (this: T, e: (DocumentEventMap | WindowEventMap)[K]) {
const target = e?.target as HTMLElement;
if (!target?.matches(sourceOrSelector)) return;
const target = (e?.target as HTMLElement)?.closest(sourceOrSelector) as unknown as T | null | undefined;
if (target == null) return;
listener(e, target as unknown as T);
listener(e, target);
}; };
document.addEventListener(name, filteredListener as EventListener, options ?? true); document.addEventListener(name, filteredListener as EventListener, options ?? true);

+ 13
- 7
src/webviews/rebase/protocol.ts View File

@ -2,11 +2,10 @@ import { IpcCommandType, IpcNotificationType } from '../protocol';
export interface State { export interface State {
branch: string; branch: string;
onto: string;
onto: { sha: string; commit?: Commit } | undefined;
entries: RebaseEntry[]; entries: RebaseEntry[];
authors: Author[];
commits: Commit[];
authors: Record<string, Author>;
commands: { commands: {
commit: string; commit: string;
}; };
@ -16,9 +15,11 @@ export interface State {
export interface RebaseEntry { export interface RebaseEntry {
readonly action: RebaseEntryAction; readonly action: RebaseEntryAction;
readonly ref: string;
readonly sha: string;
readonly message: string; readonly message: string;
readonly index: number; readonly index: number;
commit?: Commit;
} }
export type RebaseEntryAction = 'pick' | 'reword' | 'edit' | 'squash' | 'fixup' | 'break' | 'drop'; export type RebaseEntryAction = 'pick' | 'reword' | 'edit' | 'squash' | 'fixup' | 'break' | 'drop';
@ -30,7 +31,7 @@ export interface Author {
} }
export interface Commit { export interface Commit {
readonly ref: string;
readonly sha: string;
readonly author: string; readonly author: string;
// readonly avatarUrl: string; // readonly avatarUrl: string;
readonly date: string; readonly date: string;
@ -54,18 +55,23 @@ export interface ReorderParams {
export const ReorderCommandType = new IpcCommandType<ReorderParams>('rebase/reorder'); export const ReorderCommandType = new IpcCommandType<ReorderParams>('rebase/reorder');
export interface ChangeEntryParams { export interface ChangeEntryParams {
ref: string;
sha: string;
action: RebaseEntryAction; action: RebaseEntryAction;
} }
export const ChangeEntryCommandType = new IpcCommandType<ChangeEntryParams>('rebase/change/entry'); export const ChangeEntryCommandType = new IpcCommandType<ChangeEntryParams>('rebase/change/entry');
export interface MoveEntryParams { export interface MoveEntryParams {
ref: string;
sha: string;
to: number; to: number;
relative: boolean; relative: boolean;
} }
export const MoveEntryCommandType = new IpcCommandType<MoveEntryParams>('rebase/move/entry'); export const MoveEntryCommandType = new IpcCommandType<MoveEntryParams>('rebase/move/entry');
export interface UpdateSelectionParams {
sha: string;
}
export const UpdateSelectionCommandType = new IpcCommandType<UpdateSelectionParams>('rebase/selection/update');
// NOTIFICATIONS // NOTIFICATIONS
export interface DidChangeParams { export interface DidChangeParams {

+ 308
- 264
src/webviews/rebase/rebaseEditor.ts View File

@ -2,20 +2,32 @@ import type { CancellationToken, CustomTextEditorProvider, TextDocument, Webview
import { ConfigurationTarget, Disposable, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'; import { ConfigurationTarget, Disposable, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode';
import { getNonce } from '@env/crypto'; import { getNonce } from '@env/crypto';
import { ShowQuickCommitCommand } from '../../commands'; import { ShowQuickCommitCommand } from '../../commands';
import { GitActions } from '../../commands/gitCommands.actions';
import { configuration } from '../../configuration'; import { configuration } from '../../configuration';
import { CoreCommands } from '../../constants'; import { CoreCommands } from '../../constants';
import type { Container } from '../../container'; import type { Container } from '../../container';
import type { GitCommit } from '../../git/models/commit';
import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository';
import { Logger } from '../../logger'; import { Logger } from '../../logger';
import { showRebaseSwitchToTextWarningMessage } from '../../messages'; import { showRebaseSwitchToTextWarningMessage } from '../../messages';
import { executeCoreCommand } from '../../system/command'; import { executeCoreCommand } from '../../system/command';
import { gate } from '../../system/decorators/gate';
import { debug } from '../../system/decorators/log';
import { debug, log } from '../../system/decorators/log';
import type { Deferrable } from '../../system/function';
import { debounce } from '../../system/function';
import { join, map } from '../../system/iterable'; import { join, map } from '../../system/iterable';
import { normalizePath } from '../../system/path'; import { normalizePath } from '../../system/path';
import type { IpcMessage } from '../protocol'; import type { IpcMessage } from '../protocol';
import { onIpc } from '../protocol'; import { onIpc } from '../protocol';
import type { Author, Commit, RebaseEntry, RebaseEntryAction, ReorderParams, State } from './protocol';
import type {
Author,
ChangeEntryParams,
MoveEntryParams,
RebaseEntry,
RebaseEntryAction,
ReorderParams,
State,
UpdateSelectionParams,
} from './protocol';
import { import {
AbortCommandType, AbortCommandType,
ChangeEntryCommandType, ChangeEntryCommandType,
@ -26,6 +38,7 @@ import {
SearchCommandType, SearchCommandType,
StartCommandType, StartCommandType,
SwitchCommandType, SwitchCommandType,
UpdateSelectionCommandType,
} from './protocol'; } from './protocol';
const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers)
@ -79,8 +92,13 @@ interface RebaseEditorContext {
readonly repoPath: string; readonly repoPath: string;
readonly subscriptions: Disposable[]; readonly subscriptions: Disposable[];
abortOnClose: boolean;
authors?: Map<string, Author>;
branchName?: string | null;
commits?: GitCommit[];
pendingChange?: boolean; pendingChange?: boolean;
fireSelectionChangedDebounced?: Deferrable<RebaseEditorProvider['fireSelectionChanged']> | undefined;
notifyDidChangeStateDebounced?: Deferrable<RebaseEditorProvider['notifyDidChangeState']> | undefined;
} }
export class RebaseEditorProvider implements CustomTextEditorProvider, Disposable { export class RebaseEditorProvider implements CustomTextEditorProvider, Disposable {
@ -155,7 +173,7 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
await configuration.updateAny('workbench.editorAssociations', associations, ConfigurationTarget.Global); await configuration.updateAny('workbench.editorAssociations', associations, ConfigurationTarget.Global);
} }
@debug({ args: false })
@debug<RebaseEditorProvider['resolveCustomTextEditor']>({ args: { 0: d => d.uri.toString(true) } })
async resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel, _token: CancellationToken) { async resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel, _token: CancellationToken) {
const repoPath = normalizePath(Uri.joinPath(document.uri, '..', '..', '..').fsPath); const repoPath = normalizePath(Uri.joinPath(document.uri, '..', '..', '..').fsPath);
const repo = this.container.git.getRepository(repoPath); const repo = this.container.git.getRepository(repoPath);
@ -169,32 +187,27 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
document: document, document: document,
panel: panel, panel: panel,
repoPath: repo?.path ?? repoPath, repoPath: repo?.path ?? repoPath,
abortOnClose: true,
}; };
subscriptions.push( subscriptions.push(
panel.onDidDispose(() => { panel.onDidDispose(() => {
// If the user closed this without taking an action, consider it an abort
if (context.abortOnClose) {
void this.abort(context);
}
Disposable.from(...subscriptions).dispose(); Disposable.from(...subscriptions).dispose();
}), }),
panel.onDidChangeViewState(() => { panel.onDidChangeViewState(() => {
if (!context.pendingChange) return; if (!context.pendingChange) return;
void this.getStateAndNotify(context);
this.updateState(context);
}), }),
panel.webview.onDidReceiveMessage(e => this.onMessageReceived(context, e)), panel.webview.onDidReceiveMessage(e => this.onMessageReceived(context, e)),
workspace.onDidChangeTextDocument(e => { workspace.onDidChangeTextDocument(e => {
if (e.contentChanges.length === 0 || e.document.uri.toString() !== document.uri.toString()) return; if (e.contentChanges.length === 0 || e.document.uri.toString() !== document.uri.toString()) return;
void this.getStateAndNotify(context);
this.updateState(context, true);
}), }),
workspace.onDidSaveTextDocument(e => { workspace.onDidSaveTextDocument(e => {
if (e.uri.toString() !== document.uri.toString()) return; if (e.uri.toString() !== document.uri.toString()) return;
void this.getStateAndNotify(context);
this.updateState(context, true);
}), }),
); );
@ -203,7 +216,7 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
repo.onDidChange(e => { repo.onDidChange(e => {
if (!e.changed(RepositoryChange.Rebase, RepositoryChangeComparisonMode.Any)) return; if (!e.changed(RepositoryChange.Rebase, RepositoryChangeComparisonMode.Any)) return;
void this.getStateAndNotify(context);
this.updateState(context);
}), }),
); );
} }
@ -217,31 +230,12 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
} }
} }
@gate((context: RebaseEditorContext) => `${context.id}`)
private async getStateAndNotify(context: RebaseEditorContext) {
if (!context.panel.visible) {
context.pendingChange = true;
return;
}
const state = await this.parseState(context);
void this.postMessage(context, {
id: nextIpcId(),
method: DidChangeNotificationType.method,
params: { state: state },
});
}
private async parseState(context: RebaseEditorContext): Promise<State> { private async parseState(context: RebaseEditorContext): Promise<State> {
const branch = await this.container.git.getBranch(context.repoPath);
const state = await parseRebaseTodo(
this.container,
context.document.getText(),
context.repoPath,
branch?.name,
this.ascending,
);
if (context.branchName === undefined) {
const branch = await this.container.git.getBranch(context.repoPath);
context.branchName = branch?.name ?? null;
}
const state = await this.parseRebaseTodo(context);
return state; return state;
} }
@ -285,156 +279,209 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
break; break;
case SwitchCommandType.method: case SwitchCommandType.method:
onIpc(SwitchCommandType, e, () => this.switch(context));
onIpc(SwitchCommandType, e, () => this.switchToText(context));
break; break;
case ReorderCommandType.method: case ReorderCommandType.method:
onIpc(ReorderCommandType, e, params => {
this.reorder(params, context);
});
onIpc(ReorderCommandType, e, params => this.swapOrdering(params, context));
break; break;
case ChangeEntryCommandType.method: case ChangeEntryCommandType.method:
onIpc(ChangeEntryCommandType, e, async params => {
const entries = parseRebaseTodoEntries(context.document);
onIpc(ChangeEntryCommandType, e, params => this.onEntryChanged(context, params));
break;
const entry = entries.find(e => e.ref === params.ref);
if (entry == null) return;
case MoveEntryCommandType.method:
onIpc(MoveEntryCommandType, e, params => this.onEntryMoved(context, params));
break;
const start = context.document.positionAt(entry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line, maxSmallIntegerV8)),
);
case UpdateSelectionCommandType.method:
onIpc(UpdateSelectionCommandType, e, params => this.onSelectionChanged(context, params));
}
}
let action = params.action;
const edit = new WorkspaceEdit();
private async onEntryChanged(context: RebaseEditorContext, params: ChangeEntryParams) {
const entries = parseRebaseTodoEntries(context.document);
// Fake the new set of entries, to check if last entry is a squash/fixup
const newEntries = [...entries];
newEntries.splice(entries.indexOf(entry), 1, {
...entry,
action: params.action,
});
const entry = entries.find(e => e.sha === params.sha);
if (entry == null) return;
let squashing = false;
for (const entry of newEntries) {
if (entry.action === 'squash' || entry.action === 'fixup') {
squashing = true;
} else if (squashing) {
if (entry.action !== 'drop') {
squashing = false;
}
}
}
// Ensure that the last entry isn't a squash/fixup
if (squashing) {
const lastEntry = newEntries[newEntries.length - 1];
if (entry.ref === lastEntry.ref) {
action = 'pick';
} else {
const start = context.document.positionAt(lastEntry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line, maxSmallIntegerV8)),
);
edit.replace(context.document.uri, range, `pick ${lastEntry.ref} ${lastEntry.message}`);
}
}
edit.replace(context.document.uri, range, `${action} ${entry.ref} ${entry.message}`);
await workspace.applyEdit(edit);
});
const start = context.document.positionAt(entry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line, maxSmallIntegerV8)),
);
break;
let action = params.action;
const edit = new WorkspaceEdit();
case MoveEntryCommandType.method:
onIpc(MoveEntryCommandType, e, async params => {
const entries = parseRebaseTodoEntries(context.document);
const entry = entries.find(e => e.ref === params.ref);
if (entry == null) return;
const index = entries.findIndex(e => e.ref === params.ref);
let newIndex;
if (params.relative) {
if ((params.to === -1 && index === 0) || (params.to === 1 && index === entries.length - 1)) {
return;
}
newIndex = index + params.to;
} else {
if (index === params.to) return;
newIndex = params.to;
}
const newEntry = entries[newIndex];
let newLine = context.document.positionAt(newEntry.index).line;
if (newIndex < index) {
newLine++;
}
const start = context.document.positionAt(entry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line + 1, 0)),
);
// Fake the new set of entries, so we can ensure that the last entry isn't a squash/fixup
const newEntries = [...entries];
newEntries.splice(index, 1);
newEntries.splice(newIndex, 0, entry);
let squashing = false;
for (const entry of newEntries) {
if (entry.action === 'squash' || entry.action === 'fixup') {
squashing = true;
} else if (squashing) {
if (entry.action !== 'drop') {
squashing = false;
}
}
}
const edit = new WorkspaceEdit();
let action = entry.action;
// Ensure that the last entry isn't a squash/fixup
if (squashing) {
const lastEntry = newEntries[newEntries.length - 1];
if (entry.ref === lastEntry.ref) {
action = 'pick';
} else {
const start = context.document.positionAt(lastEntry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line, maxSmallIntegerV8)),
);
edit.replace(context.document.uri, range, `pick ${lastEntry.ref} ${lastEntry.message}`);
}
}
edit.delete(context.document.uri, range);
edit.insert(
context.document.uri,
new Position(newLine, 0),
`${action} ${entry.ref} ${entry.message}\n`,
);
await workspace.applyEdit(edit);
});
// Fake the new set of entries, to check if last entry is a squash/fixup
const newEntries = [...entries];
newEntries.splice(entries.indexOf(entry), 1, {
...entry,
action: params.action,
});
break;
let squashing = false;
for (const entry of newEntries) {
if (entry.action === 'squash' || entry.action === 'fixup') {
squashing = true;
} else if (squashing) {
if (entry.action !== 'drop') {
squashing = false;
}
}
}
// Ensure that the last entry isn't a squash/fixup
if (squashing) {
const lastEntry = newEntries[newEntries.length - 1];
if (entry.sha === lastEntry.sha) {
action = 'pick';
} else {
const start = context.document.positionAt(lastEntry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line, maxSmallIntegerV8)),
);
edit.replace(context.document.uri, range, `pick ${lastEntry.sha} ${lastEntry.message}`);
}
} }
edit.replace(context.document.uri, range, `${action} ${entry.sha} ${entry.message}`);
await workspace.applyEdit(edit);
} }
private async abort(context: RebaseEditorContext) {
context.abortOnClose = false;
private async onEntryMoved(context: RebaseEditorContext, params: MoveEntryParams) {
const entries = parseRebaseTodoEntries(context.document);
const entry = entries.find(e => e.sha === params.sha);
if (entry == null) return;
const index = entries.findIndex(e => e.sha === params.sha);
let newIndex;
if (params.relative) {
if ((params.to === -1 && index === 0) || (params.to === 1 && index === entries.length - 1)) {
return;
}
newIndex = index + params.to;
} else {
if (index === params.to) return;
newIndex = params.to;
}
const newEntry = entries[newIndex];
let newLine = context.document.positionAt(newEntry.index).line;
if (newIndex < index) {
newLine++;
}
const start = context.document.positionAt(entry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line + 1, 0)),
);
// Fake the new set of entries, so we can ensure that the last entry isn't a squash/fixup
const newEntries = [...entries];
newEntries.splice(index, 1);
newEntries.splice(newIndex, 0, entry);
let squashing = false;
for (const entry of newEntries) {
if (entry.action === 'squash' || entry.action === 'fixup') {
squashing = true;
} else if (squashing) {
if (entry.action !== 'drop') {
squashing = false;
}
}
}
const edit = new WorkspaceEdit();
let action = entry.action;
// Ensure that the last entry isn't a squash/fixup
if (squashing) {
const lastEntry = newEntries[newEntries.length - 1];
if (entry.sha === lastEntry.sha) {
action = 'pick';
} else {
const start = context.document.positionAt(lastEntry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line, maxSmallIntegerV8)),
);
edit.replace(context.document.uri, range, `pick ${lastEntry.sha} ${lastEntry.message}`);
}
}
edit.delete(context.document.uri, range);
edit.insert(context.document.uri, new Position(newLine, 0), `${action} ${entry.sha} ${entry.message}\n`);
await workspace.applyEdit(edit);
}
private onSelectionChanged(context: RebaseEditorContext, params: UpdateSelectionParams) {
if (context.fireSelectionChangedDebounced == null) {
context.fireSelectionChangedDebounced = debounce(this.fireSelectionChanged.bind(this), 250);
}
void context.fireSelectionChangedDebounced(context, params.sha);
}
private async fireSelectionChanged(context: RebaseEditorContext, sha: string | undefined) {
let commit: GitCommit | undefined;
if (sha != null) {
commit = await this.container.git.getCommit(context.repoPath, sha);
}
if (commit == null) return;
void GitActions.Commit.showDetailsView(commit, {
pin: true,
preserveFocus: true,
preserveVisibility: false,
});
}
@debug<RebaseEditorProvider['updateState']>({ args: { 0: c => `${c.id}:${c.document.uri.toString(true)}` } })
private updateState(context: RebaseEditorContext, immediate: boolean = false) {
if (immediate) {
context.notifyDidChangeStateDebounced?.cancel();
void this.notifyDidChangeState(context);
return;
}
if (context.notifyDidChangeStateDebounced == null) {
context.notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 250);
}
void context.notifyDidChangeStateDebounced(context);
}
@debug<RebaseEditorProvider['notifyDidChangeState']>({
args: { 0: c => `${c.id}:${c.document.uri.toString(true)}` },
})
private async notifyDidChangeState(context: RebaseEditorContext) {
if (!context.panel.visible) {
context.pendingChange = true;
return;
}
const state = await this.parseState(context);
void this.postMessage(context, {
id: nextIpcId(),
method: DidChangeNotificationType.method,
params: { state: state },
});
}
@log({ args: false })
private async abort(context: RebaseEditorContext) {
// Avoid triggering events by disposing them first // Avoid triggering events by disposing them first
context.dispose(); context.dispose();
@ -447,14 +494,14 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
context.panel.dispose(); context.panel.dispose();
} }
@log({ args: false })
private async disable(context: RebaseEditorContext) { private async disable(context: RebaseEditorContext) {
await this.abort(context); await this.abort(context);
await this.setEnabled(false); await this.setEnabled(false);
} }
@log({ args: false })
private async rebase(context: RebaseEditorContext) { private async rebase(context: RebaseEditorContext) {
context.abortOnClose = false;
// Avoid triggering events by disposing them first // Avoid triggering events by disposing them first
context.dispose(); context.dispose();
@ -463,9 +510,15 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
context.panel.dispose(); context.panel.dispose();
} }
private switch(context: RebaseEditorContext) {
context.abortOnClose = false;
@log({ args: false })
private swapOrdering(params: ReorderParams, context: RebaseEditorContext) {
this.ascending = params.ascending ?? false;
void configuration.updateEffective('rebaseEditor.ordering', this.ascending ? 'asc' : 'desc');
this.updateState(context, true);
}
@log({ args: false })
private switchToText(context: RebaseEditorContext) {
void showRebaseSwitchToTextWarningMessage(); void showRebaseSwitchToTextWarningMessage();
// Open the text version of the document // Open the text version of the document
@ -475,12 +528,6 @@ 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<string> { private async getHtml(context: RebaseEditorContext): Promise<string> {
const webRootUri = Uri.joinPath(this.container.context.extensionUri, 'dist', 'webviews'); const webRootUri = Uri.joinPath(this.container.context.extensionUri, 'dist', 'webviews');
const uri = Uri.joinPath(webRootUri, 'rebase.html'); const uri = Uri.joinPath(webRootUri, 'rebase.html');
@ -519,98 +566,95 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
return html; return html;
} }
}
async function parseRebaseTodo(
container: Container,
contents: string | { entries: RebaseEntry[]; onto: string },
repoPath: string,
branch: string | undefined,
ascending: boolean,
): Promise<Omit<State, 'rebasing'>> {
let onto: string;
let entries;
if (typeof contents === 'string') {
entries = parseRebaseTodoEntries(contents);
[, , , onto] = rebaseRegex.exec(contents) ?? ['', '', ''];
} else {
({ entries, onto } = contents);
}
@debug({ args: false })
private async parseRebaseTodo(context: RebaseEditorContext): Promise<Omit<State, 'rebasing'>> {
const contents = context.document.getText();
const entries = parseRebaseTodoEntries(contents);
let [, , , onto] = rebaseRegex.exec(contents) ?? ['', '', ''];
const authors = new Map<string, Author>();
const commits: Commit[] = [];
const log = await container.git.richSearchCommits(repoPath, {
query: `${onto ? `#:${onto} ` : ''}${join(
map(entries, e => `#:${e.ref}`),
' ',
)}`,
});
const foundCommits = log != null ? [...log.commits.values()] : [];
const ontoCommit = onto ? foundCommits.find(c => c.ref.startsWith(onto)) : undefined;
if (ontoCommit != null) {
const { name, email } = ontoCommit.author;
if (!authors.has(name)) {
authors.set(name, {
author: name,
avatarUrl: (
await ontoCommit.getAvatarUri({ defaultStyle: configuration.get('defaultGravatarsStyle') })
).toString(true),
email: email,
});
if (context.authors == null || context.commits == null) {
await this.loadRichCommitData(context, onto, entries);
} }
commits.push({
ref: ontoCommit.ref,
author: name,
date: ontoCommit.formatDate(configuration.get('defaultDateFormat')),
dateFromNow: ontoCommit.formatDateFromNow(),
message: ontoCommit.message || 'root',
});
}
const defaultDateFormat = configuration.get('defaultDateFormat');
const command = ShowQuickCommitCommand.getMarkdownCommandArgs(`\${commit}`, context.repoPath);
for (const entry of entries) {
const commit = foundCommits.find(c => c.ref.startsWith(entry.ref));
if (commit == null) continue;
const ontoCommit = onto ? context.commits?.find(c => c.sha.startsWith(onto)) : undefined;
// If the onto commit is contained in the list of commits, remove it and clear the 'onto' value — See #1201
if (commit.ref === ontoCommit?.ref) {
commits.splice(0, 1);
onto = '';
}
let commit;
for (const entry of entries) {
commit = context.commits?.find(c => c.sha.startsWith(entry.sha));
if (commit == null) continue;
const { name, email } = commit.author;
if (!authors.has(name)) {
authors.set(name, {
author: name,
avatarUrl: (
await commit.getAvatarUri({ defaultStyle: configuration.get('defaultGravatarsStyle') })
).toString(true),
email: email,
});
// If the onto commit is contained in the list of commits, remove it and clear the 'onto' value — See #1201
if (commit.sha === ontoCommit?.sha) {
onto = '';
}
entry.commit = {
sha: commit.sha,
author: commit.author.name,
date: commit.formatDate(defaultDateFormat),
dateFromNow: commit.formatDateFromNow(),
message: commit.message ?? commit.summary,
};
} }
commits.push({
ref: commit.ref,
author: name,
date: commit.formatDate(configuration.get('defaultDateFormat')),
dateFromNow: commit.formatDateFromNow(),
message: commit.message ?? commit.summary,
});
return {
branch: context.branchName ?? '',
onto: onto
? {
sha: onto,
commit:
ontoCommit != null
? {
sha: ontoCommit.sha,
author: ontoCommit.author.name,
date: ontoCommit.formatDate(defaultDateFormat),
dateFromNow: ontoCommit.formatDateFromNow(),
message: ontoCommit.message || 'root',
}
: undefined,
}
: undefined,
entries: entries,
authors: context.authors != null ? Object.fromEntries(context.authors) : {},
commands: { commit: command },
ascending: this.ascending,
};
} }
return {
branch: branch ?? '',
onto: onto,
entries: entries,
authors: [...authors.values()],
commits: commits,
commands: {
commit: ShowQuickCommitCommand.getMarkdownCommandArgs(`\${commit}`, repoPath),
},
ascending: ascending,
};
@debug({ args: false })
private async loadRichCommitData(context: RebaseEditorContext, onto: string, entries: RebaseEntry[]) {
context.commits = [];
context.authors = new Map<string, Author>();
const log = await this.container.git.richSearchCommits(
context.repoPath,
{
query: `${onto ? `#:${onto} ` : ''}${join(
map(entries, e => `#:${e.sha}`),
' ',
)}`,
},
{ limit: 0 },
);
if (log != null) {
for (const c of log.commits.values()) {
context.commits.push(c);
if (!context.authors.has(c.author.name)) {
context.authors.set(c.author.name, {
author: c.author.name,
avatarUrl: (await c.getAvatarUri()).toString(true),
email: c.author.email,
});
}
}
}
}
} }
function parseRebaseTodoEntries(contents: string): RebaseEntry[]; function parseRebaseTodoEntries(contents: string): RebaseEntry[];
@ -622,20 +666,20 @@ function parseRebaseTodoEntries(contentsOrDocument: string | TextDocument): Reba
let match; let match;
let action; let action;
let ref;
let sha;
let message; let message;
do { do {
match = rebaseCommandsRegex.exec(contents); match = rebaseCommandsRegex.exec(contents);
if (match == null) break; if (match == null) break;
[, action, ref, message] = match;
[, action, sha, message] = match;
entries.push({ entries.push({
index: match.index, index: match.index,
action: rebaseActionsMap.get(action) ?? 'pick', action: rebaseActionsMap.get(action) ?? 'pick',
// Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869
ref: ` ${ref}`.substr(1),
sha: ` ${sha}`.substr(1),
// Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869
message: message == null || message.length === 0 ? '' : ` ${message}`.substr(1), message: message == null || message.length === 0 ? '' : ` ${message}`.substr(1),
}); });

Loading…
Cancel
Save