|
@ -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; |
|
|
|
|
|
} |
|
|
|
|
|
}); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |