diff --git a/package.json b/package.json index e26e6b6..42e022f 100644 --- a/package.json +++ b/package.json @@ -3719,7 +3719,7 @@ }, { "command": "gitlens.showTimelinePage", - "title": "Open Visual File History", + "title": "Open Visual File History of Active File", "category": "GitLens", "icon": { "dark": "images/dark/icon-history.svg", @@ -3727,6 +3727,12 @@ } }, { + "command": "gitlens.refreshTimelinePage", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { "command": "gitlens.showWelcomePage", "title": "Welcome (Quick Setup)", "category": "GitLens" @@ -5841,6 +5847,12 @@ "category": "GitLens" }, { + "command": "gitlens.views.timeline.openInTab", + "title": "Open in Editor Area", + "category": "GitLens", + "icon": "$(link-external)" + }, + { "command": "gitlens.views.timeline.refresh", "title": "Refresh", "category": "GitLens", @@ -5998,6 +6010,14 @@ "when": "false" }, { + "command": "gitlens.showTimelinePage", + "when": "gitlens:enabled && editorFocus" + }, + { + "command": "gitlens.refreshTimelinePage", + "when": "false" + }, + { "command": "gitlens.showBranchesView", "when": "gitlens:enabled" }, @@ -7398,6 +7418,10 @@ "when": "false" }, { + "command": "gitlens.views.timeline.openInTab", + "when": "false" + }, + { "command": "gitlens.views.timeline.refresh", "when": "false" }, @@ -7647,6 +7671,11 @@ "command": "gitlens.clearFileAnnotations", "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computed && config.gitlens.menus.editorGroup.blame", "group": "navigation@100" + }, + { + "command": "gitlens.refreshTimelinePage", + "when": "gitlens:timelinePage:focused", + "group": "navigation@-99" } ], "editor/title/context": [ @@ -8399,6 +8428,11 @@ "group": "5_gitlens@0" }, { + "command": "gitlens.views.timeline.openInTab", + "when": "view =~ /^gitlens\\.views\\.timeline/", + "group": "navigation@98" + }, + { "command": "gitlens.views.timeline.refresh", "when": "view =~ /^gitlens\\.views\\.timeline/", "group": "navigation@99" diff --git a/src/constants.ts b/src/constants.ts index 933448d..fbcbfce 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -193,6 +193,7 @@ export const enum Commands { ShowStashesView = 'gitlens.showStashesView', ShowTagsView = 'gitlens.showTagsView', ShowWorktreesView = 'gitlens.showWorktreesView', + RefreshTimelinePage = 'gitlens.refreshTimelinePage', ShowTimelinePage = 'gitlens.showTimelinePage', ShowTimelineView = 'gitlens.showTimelineView', ShowWelcomePage = 'gitlens.showWelcomePage', @@ -242,6 +243,7 @@ export const enum ContextKeys { HasRichRemotes = 'gitlens:hasRichRemotes', HasVirtualFolders = 'gitlens:hasVirtualFolders', Readonly = 'gitlens:readonly', + TimelinePageFocused = 'gitlens:timelinePage:focused', Untrusted = 'gitlens:untrusted', ViewsCanCompare = 'gitlens:views:canCompare', ViewsCanCompareFile = 'gitlens:views:canCompare:file', diff --git a/src/premium/webviews/timeline/timelineWebview.ts b/src/premium/webviews/timeline/timelineWebview.ts index 4095e32..efd4266 100644 --- a/src/premium/webviews/timeline/timelineWebview.ts +++ b/src/premium/webviews/timeline/timelineWebview.ts @@ -1,13 +1,15 @@ 'use strict'; -import { commands, ProgressLocation, TextEditor, window } from 'vscode'; +import { commands, Disposable, TextEditor, Uri, window } from 'vscode'; import { ShowQuickCommitCommandArgs } from '../../../commands'; -import { Commands } from '../../../constants'; +import { Commands, ContextKeys } from '../../../constants'; import type { Container } from '../../../container'; +import { setContext } from '../../../context'; import { PremiumFeatures } from '../../../features'; import { GitUri } from '../../../git/gitUri'; +import { RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../../../git/models'; import { createFromDateDelta } from '../../../system/date'; import { debug } from '../../../system/decorators/log'; -import { debounce } from '../../../system/function'; +import { debounce, Deferrable } from '../../../system/function'; import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; import { IpcMessage, onIpc } from '../../../webviews/protocol'; import { WebviewBase } from '../../../webviews/webviewBase'; @@ -16,13 +18,27 @@ import { Commit, DidChangeStateNotificationType, OpenDataPointCommandType, + Period, State, UpdatePeriodCommandType, } 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 { - 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 | undefined; + private _originalTitle: string; constructor(container: Container) { super( @@ -33,42 +49,84 @@ export class TimelineWebview extends WebviewBase { 'Visual File History', 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), - 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 { - 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) { switch (e.method) { case OpenDataPointCommandType.method: 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; const commandArgs: ShowQuickCommitCommandArgs = { @@ -98,50 +156,70 @@ export class TimelineWebview extends WebviewBase { case UpdatePeriodCommandType.method: onIpc(UpdatePeriodCommandType, e, params => { - this._period = params.period; - void this.notifyDidChangeState(this._editor); + if (this.updatePendingContext({ period: params.period })) { + this.updateState(true); + } }); break; } } - @debug({ args: { 0: e => e?.document.uri.toString(true) } }) - private async getState(editor: TextEditor | undefined): Promise { + @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 { 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'; - if (editor == null || !access.allowed) { + if (current.uri == null || !access.allowed) { return { - period: this._period, + period: period, title: 'There are no editors open that can provide file history information', dateFormat: dateFormat, access: access, }; } - const gitUri = await GitUri.fromUri(editor.document.uri); + const gitUri = await GitUri.fromUri(current.uri); const repoPath = gitUri.repoPath!; 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([ this.container.git.getCurrentUser(repoPath), this.container.git.getLogForFile(repoPath, gitUri.fsPath, { limit: 0, ref: gitUri.sha, - since: this.getPeriodDate().toISOString(), + since: this.getPeriodDate(period).toISOString(), }), ]); if (log == null) { return { 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, access: access, }; @@ -168,17 +246,15 @@ export class TimelineWebview extends WebviewBase { return { dataset: dataset, - period: this._period, + period: period, title: title, - uri: editor.document.uri.toString(), + uri: current.uri.toString(), dateFormat: dateFormat, access: access, }; } - private getPeriodDate(): Date { - const period = this._period ?? '3|M'; - + private getPeriodDate(period: Period): Date { const [number, unit] = period.split('|'); 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): boolean { + let changed = false; + for (const [key, value] of Object.entries(context)) { + const current = (this._context as unknown as Record)[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)[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; + } + }); } } diff --git a/src/premium/webviews/timeline/timelineWebviewView.ts b/src/premium/webviews/timeline/timelineWebviewView.ts index bcf5512..afb639f 100644 --- a/src/premium/webviews/timeline/timelineWebviewView.ts +++ b/src/premium/webviews/timeline/timelineWebviewView.ts @@ -74,7 +74,10 @@ export class TimelineWebviewView extends WebviewViewBase { } 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) { @@ -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): boolean { let changed = false; for (const [key, value] of Object.entries(context)) { diff --git a/src/webviews/apps/premium/timeline/timeline.html b/src/webviews/apps/premium/timeline/timeline.html index cd03579..2e1f121 100644 --- a/src/webviews/apps/premium/timeline/timeline.html +++ b/src/webviews/apps/premium/timeline/timeline.html @@ -6,9 +6,6 @@ .hidden { display: none !important; } - .preload .header { - visibility: hidden; - } diff --git a/src/webviews/apps/premium/timeline/timeline.scss b/src/webviews/apps/premium/timeline/timeline.scss index be03252..c0e636f 100644 --- a/src/webviews/apps/premium/timeline/timeline.scss +++ b/src/webviews/apps/premium/timeline/timeline.scss @@ -136,7 +136,6 @@ span.button-subaction { align-items: center; justify-content: flex-end; flex: 100% 0 1; - // margin: 1em; label { margin: 0 1em; @@ -148,8 +147,6 @@ span.button-subaction { position: relative; overflow: hidden; width: 100%; - // height: calc(100% - 1rem); - // height: 100%; } #chart { diff --git a/src/webviews/webviewBase.ts b/src/webviews/webviewBase.ts index f8b463c..97998b6 100644 --- a/src/webviews/webviewBase.ts +++ b/src/webviews/webviewBase.ts @@ -34,14 +34,6 @@ function nextIpcId() { return `host:${ipcSequence}`; } -const emptyCommands: Disposable[] = [ - { - dispose: function () { - /* noop */ - }, - }, -]; - export abstract class WebviewBase implements Disposable { protected readonly disposables: Disposable[] = []; protected isReady: boolean = false; @@ -104,7 +96,8 @@ export abstract class WebviewBase implements Disposable { this._panel.onDidDispose(this.onPanelDisposed, this), this._panel.onDidChangeViewState(this.onViewStateChanged, this), this._panel.webview.onDidReceiveMessage(this.onMessageReceivedCore, this), - ...this.registerCommands(), + ...(this.onInitializing?.() ?? []), + ...(this.registerCommands?.() ?? []), ); 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 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; protected includeHead?(): string | Promise; protected includeBody?(): string | Promise; protected includeEndOfBody?(): string | Promise; + protected async refresh(): Promise { + if (this._panel == null) return; + + this._panel.webview.html = await this.getHtml(this._panel.webview); + } + private onPanelDisposed() { + this.onVisibilityChanged?.(false); + this.onFocusChanged?.(false); + this._disposablePanel?.dispose(); this._disposablePanel = undefined; this._panel = undefined; @@ -146,6 +149,8 @@ export abstract class WebviewBase implements Disposable { `Webview(${this.id}).onViewStateChanged`, `active=${e.webviewPanel.active}, visible=${e.webviewPanel.visible}`, ); + this.onVisibilityChanged?.(e.webviewPanel.visible); + this.onFocusChanged?.(e.webviewPanel.active); } protected onMessageReceivedCore(e: IpcMessage) {