Browse Source

Reworks timeline page to open to a specific editor

Reduces startup impact
main
Eric Amodio 2 years ago
parent
commit
93315e4f20
7 changed files with 255 additions and 67 deletions
  1. +35
    -1
      package.json
  2. +2
    -0
      src/constants.ts
  3. +190
    -47
      src/premium/webviews/timeline/timelineWebview.ts
  4. +11
    -1
      src/premium/webviews/timeline/timelineWebviewView.ts
  5. +0
    -3
      src/webviews/apps/premium/timeline/timeline.html
  6. +0
    -3
      src/webviews/apps/premium/timeline/timeline.scss
  7. +17
    -12
      src/webviews/webviewBase.ts

+ 35
- 1
package.json View File

@ -3719,7 +3719,7 @@
}, },
{ {
"command": "gitlens.showTimelinePage", "command": "gitlens.showTimelinePage",
"title": "Open Visual File History",
"title": "Open Visual File History of Active File",
"category": "GitLens", "category": "GitLens",
"icon": { "icon": {
"dark": "images/dark/icon-history.svg", "dark": "images/dark/icon-history.svg",
@ -3727,6 +3727,12 @@
} }
}, },
{ {
"command": "gitlens.refreshTimelinePage",
"title": "Refresh",
"category": "GitLens",
"icon": "$(refresh)"
},
{
"command": "gitlens.showWelcomePage", "command": "gitlens.showWelcomePage",
"title": "Welcome (Quick Setup)", "title": "Welcome (Quick Setup)",
"category": "GitLens" "category": "GitLens"
@ -5841,6 +5847,12 @@
"category": "GitLens" "category": "GitLens"
}, },
{ {
"command": "gitlens.views.timeline.openInTab",
"title": "Open in Editor Area",
"category": "GitLens",
"icon": "$(link-external)"
},
{
"command": "gitlens.views.timeline.refresh", "command": "gitlens.views.timeline.refresh",
"title": "Refresh", "title": "Refresh",
"category": "GitLens", "category": "GitLens",
@ -5998,6 +6010,14 @@
"when": "false" "when": "false"
}, },
{ {
"command": "gitlens.showTimelinePage",
"when": "gitlens:enabled && editorFocus"
},
{
"command": "gitlens.refreshTimelinePage",
"when": "false"
},
{
"command": "gitlens.showBranchesView", "command": "gitlens.showBranchesView",
"when": "gitlens:enabled" "when": "gitlens:enabled"
}, },
@ -7398,6 +7418,10 @@
"when": "false" "when": "false"
}, },
{ {
"command": "gitlens.views.timeline.openInTab",
"when": "false"
},
{
"command": "gitlens.views.timeline.refresh", "command": "gitlens.views.timeline.refresh",
"when": "false" "when": "false"
}, },
@ -7647,6 +7671,11 @@
"command": "gitlens.clearFileAnnotations", "command": "gitlens.clearFileAnnotations",
"when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computed && config.gitlens.menus.editorGroup.blame", "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computed && config.gitlens.menus.editorGroup.blame",
"group": "navigation@100" "group": "navigation@100"
},
{
"command": "gitlens.refreshTimelinePage",
"when": "gitlens:timelinePage:focused",
"group": "navigation@-99"
} }
], ],
"editor/title/context": [ "editor/title/context": [
@ -8399,6 +8428,11 @@
"group": "5_gitlens@0" "group": "5_gitlens@0"
}, },
{ {
"command": "gitlens.views.timeline.openInTab",
"when": "view =~ /^gitlens\\.views\\.timeline/",
"group": "navigation@98"
},
{
"command": "gitlens.views.timeline.refresh", "command": "gitlens.views.timeline.refresh",
"when": "view =~ /^gitlens\\.views\\.timeline/", "when": "view =~ /^gitlens\\.views\\.timeline/",
"group": "navigation@99" "group": "navigation@99"

+ 2
- 0
src/constants.ts View File

@ -193,6 +193,7 @@ export const enum Commands {
ShowStashesView = 'gitlens.showStashesView', ShowStashesView = 'gitlens.showStashesView',
ShowTagsView = 'gitlens.showTagsView', ShowTagsView = 'gitlens.showTagsView',
ShowWorktreesView = 'gitlens.showWorktreesView', ShowWorktreesView = 'gitlens.showWorktreesView',
RefreshTimelinePage = 'gitlens.refreshTimelinePage',
ShowTimelinePage = 'gitlens.showTimelinePage', ShowTimelinePage = 'gitlens.showTimelinePage',
ShowTimelineView = 'gitlens.showTimelineView', ShowTimelineView = 'gitlens.showTimelineView',
ShowWelcomePage = 'gitlens.showWelcomePage', ShowWelcomePage = 'gitlens.showWelcomePage',
@ -242,6 +243,7 @@ export const enum ContextKeys {
HasRichRemotes = 'gitlens:hasRichRemotes', HasRichRemotes = 'gitlens:hasRichRemotes',
HasVirtualFolders = 'gitlens:hasVirtualFolders', HasVirtualFolders = 'gitlens:hasVirtualFolders',
Readonly = 'gitlens:readonly', Readonly = 'gitlens:readonly',
TimelinePageFocused = 'gitlens:timelinePage:focused',
Untrusted = 'gitlens:untrusted', Untrusted = 'gitlens:untrusted',
ViewsCanCompare = 'gitlens:views:canCompare', ViewsCanCompare = 'gitlens:views:canCompare',
ViewsCanCompareFile = 'gitlens:views:canCompare:file', ViewsCanCompareFile = 'gitlens:views:canCompare:file',

+ 190
- 47
src/premium/webviews/timeline/timelineWebview.ts View File

@ -1,13 +1,15 @@
'use strict'; 'use strict';
import { commands, ProgressLocation, TextEditor, window } from 'vscode';
import { commands, Disposable, TextEditor, Uri, window } from 'vscode';
import { ShowQuickCommitCommandArgs } from '../../../commands'; import { ShowQuickCommitCommandArgs } from '../../../commands';
import { Commands } from '../../../constants';
import { Commands, ContextKeys } from '../../../constants';
import type { Container } from '../../../container'; import type { Container } from '../../../container';
import { setContext } from '../../../context';
import { PremiumFeatures } from '../../../features'; import { PremiumFeatures } from '../../../features';
import { GitUri } from '../../../git/gitUri'; import { GitUri } from '../../../git/gitUri';
import { RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../../../git/models';
import { createFromDateDelta } from '../../../system/date'; import { createFromDateDelta } from '../../../system/date';
import { debug } from '../../../system/decorators/log'; import { debug } from '../../../system/decorators/log';
import { debounce } from '../../../system/function';
import { debounce, Deferrable } from '../../../system/function';
import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils';
import { IpcMessage, onIpc } from '../../../webviews/protocol'; import { IpcMessage, onIpc } from '../../../webviews/protocol';
import { WebviewBase } from '../../../webviews/webviewBase'; import { WebviewBase } from '../../../webviews/webviewBase';
@ -16,13 +18,27 @@ import {
Commit, Commit,
DidChangeStateNotificationType, DidChangeStateNotificationType,
OpenDataPointCommandType, OpenDataPointCommandType,
Period,
State, State,
UpdatePeriodCommandType, UpdatePeriodCommandType,
} from './protocol'; } from './protocol';
interface Context {
uri: Uri | undefined;
period: Period | undefined;
etagRepository: number | undefined;
etagSubscription: number | undefined;
}
const defaultPeriod: Period = '3|M';
export class TimelineWebview extends WebviewBase<State> { export class TimelineWebview extends WebviewBase<State> {
private _editor: TextEditor | undefined;
private _period: `${number}|${'D' | 'M' | 'Y'}` = '3|M';
private _bootstraping = true;
/** The context the webview has */
private _context: Context;
/** The context the webview should have */
private _pendingContext: Partial<Context> | undefined;
private _originalTitle: string;
constructor(container: Container) { constructor(container: Container) {
super( super(
@ -33,42 +49,84 @@ export class TimelineWebview extends WebviewBase {
'Visual File History', 'Visual File History',
Commands.ShowTimelinePage, Commands.ShowTimelinePage,
); );
this._originalTitle = this.title;
this._context = {
uri: undefined,
period: defaultPeriod,
etagRepository: 0,
etagSubscription: 0,
};
}
protected override onInitializing(): Disposable[] | undefined {
this._context = {
uri: undefined,
period: defaultPeriod,
etagRepository: 0,
etagSubscription: this.container.subscription.etag,
};
this.disposables.push(
this.updatePendingEditor(window.activeTextEditor);
this._context = { ...this._context, ...this._pendingContext };
this._pendingContext = undefined;
return [
this.container.subscription.onDidChange(this.onSubscriptionChanged, this), this.container.subscription.onDidChange(this.onSubscriptionChanged, this),
window.onDidChangeActiveTextEditor(debounce(this.onActiveEditorChanged, 500), this),
);
this.container.git.onDidChangeRepository(this.onRepositoryChanged, this),
];
} }
protected override onReady() {
this.onActiveEditorChanged(window.activeTextEditor);
protected override onShowCommand(uri?: Uri): void {
if (uri != null) {
this.updatePendingUri(uri);
} else {
this.updatePendingEditor(window.activeTextEditor);
}
this._context = { ...this._context, ...this._pendingContext };
this._pendingContext = undefined;
super.onShowCommand();
} }
protected override async includeBootstrap(): Promise<State> { protected override async includeBootstrap(): Promise<State> {
return this.getState(undefined);
this._bootstraping = true;
this._context = { ...this._context, ...this._pendingContext };
this._pendingContext = undefined;
return this.getState(this._context);
} }
@debug({ args: false })
private onActiveEditorChanged(editor: TextEditor | undefined) {
if (!this.isReady || (this._editor === editor && editor != null)) return;
if (editor == null && hasVisibleTextEditor()) return;
if (editor != null && !isTextEditor(editor)) return;
protected override registerCommands(): Disposable[] {
return [commands.registerCommand(Commands.RefreshTimelinePage, () => this.refresh())];
}
this._editor = editor;
void this.notifyDidChangeState(editor);
protected override onFocusChanged(focused: boolean): void {
if (focused) {
// If we are becoming focused, delay it a bit to give the UI time to update
setTimeout(() => void setContext(ContextKeys.TimelinePageFocused, focused), 0);
return;
}
void setContext(ContextKeys.TimelinePageFocused, focused);
} }
private onSubscriptionChanged(_e: SubscriptionChangeEvent) {
void this.notifyDidChangeState(this._editor);
protected override onVisibilityChanged(visible: boolean) {
if (!visible) return;
// Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data
if (this._bootstraping) {
this._bootstraping = false;
return;
}
this.updateState(true);
} }
protected override onMessageReceived(e: IpcMessage) { protected override onMessageReceived(e: IpcMessage) {
switch (e.method) { switch (e.method) {
case OpenDataPointCommandType.method: case OpenDataPointCommandType.method:
onIpc(OpenDataPointCommandType, e, params => { onIpc(OpenDataPointCommandType, e, params => {
if (params.data == null || this._editor == null || !params.data.selected) return;
if (params.data == null || !params.data.selected || this._context.uri == null) return;
const repository = this.container.git.getRepository(this._editor.document.uri);
const repository = this.container.git.getRepository(this._context.uri);
if (repository == null) return; if (repository == null) return;
const commandArgs: ShowQuickCommitCommandArgs = { const commandArgs: ShowQuickCommitCommandArgs = {
@ -98,50 +156,70 @@ export class TimelineWebview extends WebviewBase {
case UpdatePeriodCommandType.method: case UpdatePeriodCommandType.method:
onIpc(UpdatePeriodCommandType, e, params => { onIpc(UpdatePeriodCommandType, e, params => {
this._period = params.period;
void this.notifyDidChangeState(this._editor);
if (this.updatePendingContext({ period: params.period })) {
this.updateState(true);
}
}); });
break; break;
} }
} }
@debug({ args: { 0: e => e?.document.uri.toString(true) } })
private async getState(editor: TextEditor | undefined): Promise<State> {
@debug({ args: false })
private onRepositoryChanged(e: RepositoryChangeEvent) {
if (!e.changed(RepositoryChange.Heads, RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) {
return;
}
if (this.updatePendingContext({ etagRepository: e.repository.etag })) {
this.updateState();
}
}
@debug({ args: false })
private onSubscriptionChanged(e: SubscriptionChangeEvent) {
if (this.updatePendingContext({ etagSubscription: e.etag })) {
this.updateState();
}
}
@debug({ args: false })
private async getState(current: Context): Promise<State> {
const access = await this.container.git.access(PremiumFeatures.Timeline); const access = await this.container.git.access(PremiumFeatures.Timeline);
const period = current.period ?? defaultPeriod;
const dateFormat = this.container.config.defaultDateFormat ?? 'MMMM Do, YYYY h:mma'; const dateFormat = this.container.config.defaultDateFormat ?? 'MMMM Do, YYYY h:mma';
if (editor == null || !access.allowed) {
if (current.uri == null || !access.allowed) {
return { return {
period: this._period,
period: period,
title: 'There are no editors open that can provide file history information', title: 'There are no editors open that can provide file history information',
dateFormat: dateFormat, dateFormat: dateFormat,
access: access, access: access,
}; };
} }
const gitUri = await GitUri.fromUri(editor.document.uri);
const gitUri = await GitUri.fromUri(current.uri);
const repoPath = gitUri.repoPath!; const repoPath = gitUri.repoPath!;
const title = gitUri.relativePath; const title = gitUri.relativePath;
// this.setTitle(`${this.title} \u2022 ${gitUri.fileName}`);
// this.description = gitUri.fileName;
this.title = `${this._originalTitle}: ${gitUri.fileName}`;
const [currentUser, log] = await Promise.all([ const [currentUser, log] = await Promise.all([
this.container.git.getCurrentUser(repoPath), this.container.git.getCurrentUser(repoPath),
this.container.git.getLogForFile(repoPath, gitUri.fsPath, { this.container.git.getLogForFile(repoPath, gitUri.fsPath, {
limit: 0, limit: 0,
ref: gitUri.sha, ref: gitUri.sha,
since: this.getPeriodDate().toISOString(),
since: this.getPeriodDate(period).toISOString(),
}), }),
]); ]);
if (log == null) { if (log == null) {
return { return {
dataset: [], dataset: [],
period: this._period,
title: title,
uri: editor.document.uri.toString(),
period: period,
title: 'No commits found for the specified time period',
uri: current.uri.toString(),
dateFormat: dateFormat, dateFormat: dateFormat,
access: access, access: access,
}; };
@ -168,17 +246,15 @@ export class TimelineWebview extends WebviewBase {
return { return {
dataset: dataset, dataset: dataset,
period: this._period,
period: period,
title: title, title: title,
uri: editor.document.uri.toString(),
uri: current.uri.toString(),
dateFormat: dateFormat, dateFormat: dateFormat,
access: access, access: access,
}; };
} }
private getPeriodDate(): Date {
const period = this._period ?? '3|M';
private getPeriodDate(period: Period): Date {
const [number, unit] = period.split('|'); const [number, unit] = period.split('|');
switch (unit) { switch (unit) {
@ -193,13 +269,80 @@ export class TimelineWebview extends WebviewBase {
} }
} }
private async notifyDidChangeState(editor: TextEditor | undefined) {
if (!this.isReady) return false;
private updatePendingContext(context: Partial<Context>): boolean {
let changed = false;
for (const [key, value] of Object.entries(context)) {
const current = (this._context as unknown as Record<string, unknown>)[key];
if (
current === value ||
((current instanceof Uri || value instanceof Uri) && (current as any)?.toString() === value?.toString())
) {
continue;
}
if (this._pendingContext == null) {
this._pendingContext = {};
}
(this._pendingContext as Record<string, unknown>)[key] = value;
changed = true;
}
return window.withProgress({ location: ProgressLocation.Window }, async () =>
this.notify(DidChangeStateNotificationType, {
state: await this.getState(editor),
}),
);
return changed;
}
private updatePendingEditor(editor: TextEditor | undefined): boolean {
if (editor == null && hasVisibleTextEditor()) return false;
if (editor != null && !isTextEditor(editor)) return false;
return this.updatePendingUri(editor?.document.uri);
}
private updatePendingUri(uri: Uri | undefined): boolean {
let etag;
if (uri != null) {
const repository = this.container.git.getRepository(uri);
etag = repository?.etag ?? 0;
} else {
etag = 0;
}
return this.updatePendingContext({ uri: uri, etagRepository: etag });
}
private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined;
@debug()
private updateState(immediate: boolean = false) {
if (!this.isReady || !this.visible) return;
if (immediate) {
void this.notifyDidChangeState();
return;
}
if (this._notifyDidChangeStateDebounced == null) {
this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500);
}
this._notifyDidChangeStateDebounced();
}
@debug()
private async notifyDidChangeState() {
if (!this.isReady || !this.visible) return false;
this._notifyDidChangeStateDebounced?.cancel();
const context = { ...this._context, ...this._pendingContext };
return window.withProgress({ location: { viewId: this.id } }, async () => {
const success = await this.notify(DidChangeStateNotificationType, {
state: await this.getState(context),
});
if (success) {
this._context = context;
this._pendingContext = undefined;
}
});
} }
} }

+ 11
- 1
src/premium/webviews/timeline/timelineWebviewView.ts View File

@ -74,7 +74,10 @@ export class TimelineWebviewView extends WebviewViewBase {
} }
protected override registerCommands(): Disposable[] { protected override registerCommands(): Disposable[] {
return [commands.registerCommand(`${this.id}.refresh`, () => this.refresh(), this)];
return [
commands.registerCommand(`${this.id}.refresh`, () => this.refresh(), this),
commands.registerCommand(`${this.id}.openInTab`, () => this.openInTab(), this),
];
} }
protected override onVisibilityChanged(visible: boolean) { protected override onVisibilityChanged(visible: boolean) {
@ -244,6 +247,13 @@ export class TimelineWebviewView extends WebviewViewBase {
} }
} }
private openInTab() {
const uri = this._context.uri;
if (uri == null) return;
void commands.executeCommand(Commands.ShowTimelinePage, uri);
}
private updatePendingContext(context: Partial<Context>): boolean { private updatePendingContext(context: Partial<Context>): boolean {
let changed = false; let changed = false;
for (const [key, value] of Object.entries(context)) { for (const [key, value] of Object.entries(context)) {

+ 0
- 3
src/webviews/apps/premium/timeline/timeline.html View File

@ -6,9 +6,6 @@
.hidden { .hidden {
display: none !important; display: none !important;
} }
.preload .header {
visibility: hidden;
}
</style> </style>
</head> </head>

+ 0
- 3
src/webviews/apps/premium/timeline/timeline.scss View File

@ -136,7 +136,6 @@ span.button-subaction {
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
flex: 100% 0 1; flex: 100% 0 1;
// margin: 1em;
label { label {
margin: 0 1em; margin: 0 1em;
@ -148,8 +147,6 @@ span.button-subaction {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
// height: calc(100% - 1rem);
// height: 100%;
} }
#chart { #chart {

+ 17
- 12
src/webviews/webviewBase.ts View File

@ -34,14 +34,6 @@ function nextIpcId() {
return `host:${ipcSequence}`; return `host:${ipcSequence}`;
} }
const emptyCommands: Disposable[] = [
{
dispose: function () {
/* noop */
},
},
];
export abstract class WebviewBase<State> implements Disposable { export abstract class WebviewBase<State> implements Disposable {
protected readonly disposables: Disposable[] = []; protected readonly disposables: Disposable[] = [];
protected isReady: boolean = false; protected isReady: boolean = false;
@ -104,7 +96,8 @@ export abstract class WebviewBase implements Disposable {
this._panel.onDidDispose(this.onPanelDisposed, this), this._panel.onDidDispose(this.onPanelDisposed, this),
this._panel.onDidChangeViewState(this.onViewStateChanged, this), this._panel.onDidChangeViewState(this.onViewStateChanged, this),
this._panel.webview.onDidReceiveMessage(this.onMessageReceivedCore, this), this._panel.webview.onDidReceiveMessage(this.onMessageReceivedCore, this),
...this.registerCommands(),
...(this.onInitializing?.() ?? []),
...(this.registerCommands?.() ?? []),
); );
this._panel.webview.html = await this.getHtml(this._panel.webview); this._panel.webview.html = await this.getHtml(this._panel.webview);
@ -119,19 +112,29 @@ export abstract class WebviewBase implements Disposable {
} }
} }
protected onInitializing?(): Disposable[] | undefined;
protected onReady?(): void; protected onReady?(): void;
protected onMessageReceived?(e: IpcMessage): void; protected onMessageReceived?(e: IpcMessage): void;
protected onFocusChanged?(focused: boolean): void;
protected onVisibilityChanged?(visible: boolean): void;
protected registerCommands(): Disposable[] {
return emptyCommands;
}
protected registerCommands?(): Disposable[];
protected includeBootstrap?(): State | Promise<State>; protected includeBootstrap?(): State | Promise<State>;
protected includeHead?(): string | Promise<string>; protected includeHead?(): string | Promise<string>;
protected includeBody?(): string | Promise<string>; protected includeBody?(): string | Promise<string>;
protected includeEndOfBody?(): string | Promise<string>; protected includeEndOfBody?(): string | Promise<string>;
protected async refresh(): Promise<void> {
if (this._panel == null) return;
this._panel.webview.html = await this.getHtml(this._panel.webview);
}
private onPanelDisposed() { private onPanelDisposed() {
this.onVisibilityChanged?.(false);
this.onFocusChanged?.(false);
this._disposablePanel?.dispose(); this._disposablePanel?.dispose();
this._disposablePanel = undefined; this._disposablePanel = undefined;
this._panel = undefined; this._panel = undefined;
@ -146,6 +149,8 @@ export abstract class WebviewBase implements Disposable {
`Webview(${this.id}).onViewStateChanged`, `Webview(${this.id}).onViewStateChanged`,
`active=${e.webviewPanel.active}, visible=${e.webviewPanel.visible}`, `active=${e.webviewPanel.active}, visible=${e.webviewPanel.visible}`,
); );
this.onVisibilityChanged?.(e.webviewPanel.visible);
this.onFocusChanged?.(e.webviewPanel.active);
} }
protected onMessageReceivedCore(e: IpcMessage) { protected onMessageReceivedCore(e: IpcMessage) {

Loading…
Cancel
Save