Git - experimental git blame editor decoration (#234209)
* Initial implementation * Add setting, and cache blame information more aggressively
This commit is contained in:
Родитель
e7ee6c08df
Коммит
80635b487b
|
@ -3188,6 +3188,12 @@
|
|||
"maximum": 100,
|
||||
"markdownDescription": "%config.similarityThreshold%",
|
||||
"scope": "resource"
|
||||
},
|
||||
"git.blame.editorDecoration.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "%config.blameEditorDecoration.enabled%",
|
||||
"scope": "resource"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -3291,6 +3297,16 @@
|
|||
"highContrast": "#8db9e2",
|
||||
"highContrastLight": "#1258a7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "git.blame.editorDecorationForeground",
|
||||
"description": "%colors.blameEditorDecoration%",
|
||||
"defaults": {
|
||||
"dark": "editorCodeLens.foreground",
|
||||
"light": "editorCodeLens.foreground",
|
||||
"highContrast": "editorCodeLens.foreground",
|
||||
"highContrastLight": "editorCodeLens.foreground"
|
||||
}
|
||||
}
|
||||
],
|
||||
"configurationDefaults": {
|
||||
|
|
|
@ -276,6 +276,7 @@
|
|||
"config.publishBeforeContinueOn.never": "Never publish unpublished Git state when using Continue Working On from a Git repository",
|
||||
"config.publishBeforeContinueOn.prompt": "Prompt to publish unpublished Git state when using Continue Working On from a Git repository",
|
||||
"config.similarityThreshold": "Controls the threshold of the similarity index (the amount of additions/deletions compared to the file's size) for changes in a pair of added/deleted files to be considered a rename. **Note:** Requires Git version `2.18.0` or later.",
|
||||
"config.blameEditorDecoration.enabled": "Controls whether to show git blame information in the editor using editor decorations",
|
||||
"submenu.explorer": "Git",
|
||||
"submenu.commit": "Commit",
|
||||
"submenu.commit.amend": "Amend",
|
||||
|
@ -300,6 +301,7 @@
|
|||
"colors.incomingDeleted": "Color for deleted incoming resource.",
|
||||
"colors.incomingRenamed": "Color for renamed incoming resource.",
|
||||
"colors.incomingModified": "Color for modified incoming resource.",
|
||||
"colors.blameEditorDecoration": "Color for the blame editor decoration.",
|
||||
"view.workbench.scm.missing.windows": {
|
||||
"message": "[Download Git for Windows](https://git-scm.com/download/win)\nAfter installing, please [reload](command:workbench.action.reloadWindow) (or [troubleshoot](command:git.showOutput)). Additional source control providers can be installed [from the Marketplace](command:workbench.extensions.search?%22%40category%3A%5C%22scm%20providers%5C%22%22).",
|
||||
"comment": [
|
||||
|
|
|
@ -0,0 +1,227 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ConfigurationChangeEvent, DecorationOptions, l10n, Position, Range, TextDocument, TextEditor, TextEditorDecorationType, TextEditorDiff, TextEditorDiffKind, ThemeColor, Uri, window, workspace } from 'vscode';
|
||||
import { Model } from './model';
|
||||
import { dispose, fromNow, IDisposable } from './util';
|
||||
import { Repository } from './repository';
|
||||
import { throttle } from './decorators';
|
||||
import { BlameInformation } from './git';
|
||||
|
||||
function isLineChanged(lineNumber: number, changes: readonly TextEditorDiff[]): boolean {
|
||||
for (const change of changes) {
|
||||
// If the change is a delete, skip it
|
||||
if (change.kind === TextEditorDiffKind.Deletion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startLineNumber = change.modifiedStartLineNumber;
|
||||
const endLineNumber = change.modifiedEndLineNumber || startLineNumber;
|
||||
if (lineNumber >= startLineNumber && lineNumber <= endLineNumber) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function mapLineNumber(lineNumber: number, changes: readonly TextEditorDiff[]): number {
|
||||
if (changes.length === 0) {
|
||||
return lineNumber;
|
||||
}
|
||||
|
||||
for (const change of changes) {
|
||||
// Line number is before the change
|
||||
if ((change.kind === TextEditorDiffKind.Addition && lineNumber < change.modifiedStartLineNumber) ||
|
||||
(change.kind === TextEditorDiffKind.Modification && lineNumber < change.modifiedStartLineNumber) ||
|
||||
(change.kind === TextEditorDiffKind.Deletion && lineNumber < change.originalStartLineNumber)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Update line number
|
||||
switch (change.kind) {
|
||||
case TextEditorDiffKind.Addition:
|
||||
lineNumber = lineNumber - (change.modifiedEndLineNumber - change.originalStartLineNumber);
|
||||
break;
|
||||
case TextEditorDiffKind.Modification:
|
||||
if (change.originalStartLineNumber !== change.modifiedStartLineNumber || change.originalEndLineNumber !== change.modifiedEndLineNumber) {
|
||||
lineNumber = lineNumber - (change.modifiedEndLineNumber - change.originalEndLineNumber);
|
||||
}
|
||||
break;
|
||||
case TextEditorDiffKind.Deletion:
|
||||
lineNumber = lineNumber + (change.originalEndLineNumber - change.originalStartLineNumber) + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lineNumber;
|
||||
}
|
||||
|
||||
interface RepositoryBlameInformation {
|
||||
readonly commit: string;
|
||||
readonly blameInformation: Map<Uri, BlameInformation[]>;
|
||||
}
|
||||
|
||||
export class GitBlameController {
|
||||
private readonly _decorationType: TextEditorDecorationType;
|
||||
|
||||
private readonly _blameInformation = new Map<Repository, RepositoryBlameInformation>();
|
||||
|
||||
private _repositoryDisposables = new Map<Repository, IDisposable[]>();
|
||||
private _disposables: IDisposable[] = [];
|
||||
|
||||
constructor(private readonly _model: Model) {
|
||||
this._decorationType = window.createTextEditorDecorationType({
|
||||
isWholeLine: true,
|
||||
after: {
|
||||
color: new ThemeColor('git.blame.editorDecorationForeground')
|
||||
}
|
||||
});
|
||||
this._disposables.push(this._decorationType);
|
||||
|
||||
this._model.onDidOpenRepository(this._onDidOpenRepository, this, this._disposables);
|
||||
this._model.onDidCloseRepository(this._onDidCloseRepository, this, this._disposables);
|
||||
|
||||
workspace.onDidChangeConfiguration(this._onDidChangeConfiguration, this, this._disposables);
|
||||
|
||||
window.onDidChangeTextEditorSelection(e => this._updateDecorations(e.textEditor), this, this._disposables);
|
||||
window.onDidChangeTextEditorDiffInformation(e => this._updateDecorations(e.textEditor), this, this._disposables);
|
||||
|
||||
this._updateDecorations(window.activeTextEditor);
|
||||
}
|
||||
|
||||
private _onDidChangeConfiguration(e: ConfigurationChangeEvent): void {
|
||||
if (!e.affectsConfiguration('git.blame.editorDecoration.enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const textEditor of window.visibleTextEditors) {
|
||||
this._updateDecorations(textEditor);
|
||||
}
|
||||
}
|
||||
|
||||
private _onDidOpenRepository(repository: Repository): void {
|
||||
const disposables: IDisposable[] = [];
|
||||
|
||||
repository.onDidRunGitStatus(() => {
|
||||
if (this._blameInformation.get(repository)?.commit === repository.HEAD?.commit) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._blameInformation.delete(repository);
|
||||
for (const textEditor of window.visibleTextEditors) {
|
||||
this._updateDecorations(textEditor);
|
||||
}
|
||||
}, this, disposables);
|
||||
|
||||
this._repositoryDisposables.set(repository, disposables);
|
||||
}
|
||||
|
||||
private _onDidCloseRepository(repository: Repository): void {
|
||||
const disposables = this._repositoryDisposables.get(repository);
|
||||
if (disposables) {
|
||||
dispose(disposables);
|
||||
}
|
||||
|
||||
this._repositoryDisposables.delete(repository);
|
||||
this._blameInformation.delete(repository);
|
||||
}
|
||||
|
||||
@throttle
|
||||
private async _updateDecorations(textEditor: TextEditor | undefined): Promise<void> {
|
||||
if (!textEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = workspace.getConfiguration('git').get<boolean>('blame.editorDecoration.enabled', false);
|
||||
if (!enabled) {
|
||||
textEditor.setDecorations(this._decorationType, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const diffInformation = textEditor.diffInformation;
|
||||
if (!diffInformation || diffInformation.isStale) {
|
||||
textEditor.setDecorations(this._decorationType, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const blameInformationCollection = await this._getBlameInformation(textEditor.document);
|
||||
if (!blameInformationCollection) {
|
||||
textEditor.setDecorations(this._decorationType, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const decorations: DecorationOptions[] = [];
|
||||
for (const lineNumber of textEditor.selections.map(s => s.active.line)) {
|
||||
// Check if the line is in an add/edit change
|
||||
if (isLineChanged(lineNumber + 1, diffInformation.diff)) {
|
||||
decorations.push(this._createDecoration(lineNumber, l10n.t('Uncommitted change')));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recalculate the line number factoring in the diff information
|
||||
const lineNumberWithDiff = mapLineNumber(lineNumber + 1, diffInformation.diff);
|
||||
const blameInformation = blameInformationCollection.find(blameInformation => {
|
||||
return blameInformation.ranges.find(range => {
|
||||
return lineNumberWithDiff >= range.startLineNumber && lineNumberWithDiff <= range.endLineNumber;
|
||||
});
|
||||
});
|
||||
|
||||
if (blameInformation) {
|
||||
const ago = fromNow(blameInformation.date ?? Date.now(), true, true);
|
||||
decorations.push(this._createDecoration(lineNumber, `${blameInformation.message ?? ''}, ${blameInformation.authorName ?? ''} (${ago})`));
|
||||
}
|
||||
}
|
||||
|
||||
textEditor.setDecorations(this._decorationType, decorations);
|
||||
}
|
||||
|
||||
private _createDecoration(lineNumber: number, contentText: string): DecorationOptions {
|
||||
const position = new Position(lineNumber, Number.MAX_SAFE_INTEGER);
|
||||
|
||||
return {
|
||||
range: new Range(position, position),
|
||||
renderOptions: {
|
||||
after: {
|
||||
contentText: `\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${contentText}`
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _getBlameInformation(document: TextDocument): Promise<BlameInformation[] | undefined> {
|
||||
const repository = this._model.getRepository(document.uri);
|
||||
if (!repository || !repository.HEAD?.commit) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const repositoryBlameInformation = this._blameInformation.get(repository) ?? {
|
||||
commit: repository.HEAD.commit,
|
||||
blameInformation: new Map<Uri, BlameInformation[]>()
|
||||
} satisfies RepositoryBlameInformation;
|
||||
|
||||
let fileBlameInformation = repositoryBlameInformation.blameInformation.get(document.uri);
|
||||
if (repositoryBlameInformation.commit === repository.HEAD.commit && fileBlameInformation) {
|
||||
return fileBlameInformation;
|
||||
}
|
||||
|
||||
fileBlameInformation = await repository.blame2(document.uri.fsPath) ?? [];
|
||||
this._blameInformation.set(repository, {
|
||||
...repositoryBlameInformation,
|
||||
blameInformation: repositoryBlameInformation.blameInformation.set(document.uri, fileBlameInformation)
|
||||
});
|
||||
|
||||
return fileBlameInformation;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const disposables of this._repositoryDisposables.values()) {
|
||||
dispose(disposables);
|
||||
}
|
||||
this._repositoryDisposables.clear();
|
||||
|
||||
this._disposables = dispose(this._disposables);
|
||||
}
|
||||
}
|
|
@ -1054,6 +1054,76 @@ function parseGitChanges(repositoryRoot: string, raw: string): Change[] {
|
|||
return result;
|
||||
}
|
||||
|
||||
export interface BlameInformation {
|
||||
readonly id: string;
|
||||
readonly date?: number;
|
||||
readonly message?: string;
|
||||
readonly authorName?: string;
|
||||
readonly authorEmail?: string;
|
||||
readonly ranges: {
|
||||
readonly startLineNumber: number;
|
||||
readonly endLineNumber: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
function parseGitBlame(data: string): BlameInformation[] {
|
||||
const lineSeparator = /\r?\n/;
|
||||
const commitRegex = /^([0-9a-f]{40})/gm;
|
||||
|
||||
const blameInformation = new Map<string, BlameInformation>();
|
||||
|
||||
let commitHash: string | undefined = undefined;
|
||||
let authorName: string | undefined = undefined;
|
||||
let authorEmail: string | undefined = undefined;
|
||||
let authorTime: number | undefined = undefined;
|
||||
let message: string | undefined = undefined;
|
||||
let startLineNumber: number | undefined = undefined;
|
||||
let endLineNumber: number | undefined = undefined;
|
||||
|
||||
for (const line of data.split(lineSeparator)) {
|
||||
// Commit
|
||||
const commitMatch = line.match(commitRegex);
|
||||
if (!commitHash && commitMatch) {
|
||||
const segments = line.split(' ');
|
||||
|
||||
commitHash = commitMatch[0];
|
||||
startLineNumber = Number(segments[2]);
|
||||
endLineNumber = Number(segments[2]) + Number(segments[3]) - 1;
|
||||
}
|
||||
|
||||
// Commit properties
|
||||
if (commitHash && line.startsWith('author ')) {
|
||||
authorName = line.substring('author '.length);
|
||||
}
|
||||
if (commitHash && line.startsWith('author-mail ')) {
|
||||
authorEmail = line.substring('author-mail '.length);
|
||||
}
|
||||
if (commitHash && line.startsWith('author-time ')) {
|
||||
authorTime = Number(line.substring('author-time '.length)) * 1000;
|
||||
}
|
||||
if (commitHash && line.startsWith('summary ')) {
|
||||
message = line.substring('summary '.length);
|
||||
}
|
||||
|
||||
// Commit end
|
||||
if (commitHash && startLineNumber && endLineNumber && line.startsWith('filename ')) {
|
||||
const existingCommit = blameInformation.get(commitHash);
|
||||
if (existingCommit) {
|
||||
existingCommit.ranges.push({ startLineNumber, endLineNumber });
|
||||
blameInformation.set(commitHash, existingCommit);
|
||||
} else {
|
||||
blameInformation.set(commitHash, {
|
||||
id: commitHash, authorName, authorEmail, date: authorTime, message, ranges: [{ startLineNumber, endLineNumber }]
|
||||
});
|
||||
}
|
||||
|
||||
commitHash = authorName = authorEmail = authorTime = message = startLineNumber = endLineNumber = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(blameInformation.values());
|
||||
}
|
||||
|
||||
export interface PullOptions {
|
||||
unshallow?: boolean;
|
||||
tags?: boolean;
|
||||
|
@ -2137,6 +2207,18 @@ export class Repository {
|
|||
}
|
||||
}
|
||||
|
||||
async blame2(path: string): Promise<BlameInformation[] | undefined> {
|
||||
try {
|
||||
const args = ['blame', '--root', '--incremental', '--', sanitizePath(path)];
|
||||
const result = await this.exec(args);
|
||||
|
||||
return parseGitBlame(result.stdout.trim());
|
||||
}
|
||||
catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async createStash(message?: string, includeUntracked?: boolean, staged?: boolean): Promise<void> {
|
||||
try {
|
||||
const args = ['stash', 'push'];
|
||||
|
|
|
@ -26,6 +26,7 @@ import { GitEditor, GitEditorDocumentLinkProvider } from './gitEditor';
|
|||
import { GitPostCommitCommandsProvider } from './postCommitCommands';
|
||||
import { GitEditSessionIdentityProvider } from './editSessionIdentityProvider';
|
||||
import { GitCommitInputBoxCodeActionsProvider, GitCommitInputBoxDiagnosticsManager } from './diagnostics';
|
||||
import { GitBlameController } from './blame';
|
||||
|
||||
const deactivateTasks: { (): Promise<any> }[] = [];
|
||||
|
||||
|
@ -112,6 +113,7 @@ async function createModel(context: ExtensionContext, logger: LogOutputChannel,
|
|||
cc,
|
||||
new GitFileSystemProvider(model),
|
||||
new GitDecorations(model),
|
||||
new GitBlameController(model),
|
||||
new GitTimelineProvider(model, cc),
|
||||
new GitEditSessionIdentityProvider(model),
|
||||
new TerminalShellExecutionManager(model, logger)
|
||||
|
|
|
@ -134,7 +134,7 @@ export type TagOperation = BaseOperation & { kind: OperationKind.Tag };
|
|||
export const Operation = {
|
||||
Add: (showProgress: boolean): AddOperation => ({ kind: OperationKind.Add, blocking: false, readOnly: false, remote: false, retry: false, showProgress }),
|
||||
Apply: { kind: OperationKind.Apply, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as ApplyOperation,
|
||||
Blame: { kind: OperationKind.Blame, blocking: false, readOnly: true, remote: false, retry: false, showProgress: true } as BlameOperation,
|
||||
Blame: (showProgress: boolean) => ({ kind: OperationKind.Blame, blocking: false, readOnly: true, remote: false, retry: false, showProgress } as BlameOperation),
|
||||
Branch: { kind: OperationKind.Branch, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as BranchOperation,
|
||||
CheckIgnore: { kind: OperationKind.CheckIgnore, blocking: false, readOnly: true, remote: false, retry: false, showProgress: false } as CheckIgnoreOperation,
|
||||
CherryPick: { kind: OperationKind.CherryPick, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as CherryPickOperation,
|
||||
|
|
|
@ -14,7 +14,7 @@ import { Branch, BranchQuery, Change, CommitOptions, FetchOptions, ForcePushMode
|
|||
import { AutoFetcher } from './autofetch';
|
||||
import { GitBranchProtectionProvider, IBranchProtectionProviderRegistry } from './branchProtection';
|
||||
import { debounce, memoize, throttle } from './decorators';
|
||||
import { Repository as BaseRepository, Commit, GitError, LogFileOptions, LsTreeElement, PullOptions, Stash, Submodule } from './git';
|
||||
import { Repository as BaseRepository, BlameInformation, Commit, GitError, LogFileOptions, LsTreeElement, PullOptions, Stash, Submodule } from './git';
|
||||
import { GitHistoryProvider } from './historyProvider';
|
||||
import { Operation, OperationKind, OperationManager, OperationResult } from './operation';
|
||||
import { CommitCommandsCenter, IPostCommitCommandsProviderRegistry } from './postCommitCommands';
|
||||
|
@ -1783,7 +1783,11 @@ export class Repository implements Disposable {
|
|||
}
|
||||
|
||||
async blame(path: string): Promise<string> {
|
||||
return await this.run(Operation.Blame, () => this.repository.blame(path));
|
||||
return await this.run(Operation.Blame(true), () => this.repository.blame(path));
|
||||
}
|
||||
|
||||
async blame2(path: string): Promise<BlameInformation[] | undefined> {
|
||||
return await this.run(Operation.Blame(false), () => this.repository.blame2(path));
|
||||
}
|
||||
|
||||
@throttle
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef } from 'vscode';
|
||||
import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef, l10n } from 'vscode';
|
||||
import { dirname, sep, relative } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { promises as fs, createReadStream } from 'fs';
|
||||
|
@ -568,3 +568,197 @@ export function deltaHistoryItemRefs(before: SourceControlHistoryItemRef[], afte
|
|||
|
||||
return { added, modified, removed };
|
||||
}
|
||||
|
||||
const minute = 60;
|
||||
const hour = minute * 60;
|
||||
const day = hour * 24;
|
||||
const week = day * 7;
|
||||
const month = day * 30;
|
||||
const year = day * 365;
|
||||
|
||||
/**
|
||||
* Create a l10n.td difference of the time between now and the specified date.
|
||||
* @param date The date to generate the difference from.
|
||||
* @param appendAgoLabel Whether to append the " ago" to the end.
|
||||
* @param useFullTimeWords Whether to use full words (eg. seconds) instead of
|
||||
* shortened (eg. secs).
|
||||
* @param disallowNow Whether to disallow the string "now" when the difference
|
||||
* is less than 30 seconds.
|
||||
*/
|
||||
export function fromNow(date: number | Date, appendAgoLabel?: boolean, useFullTimeWords?: boolean, disallowNow?: boolean): string {
|
||||
if (typeof date !== 'number') {
|
||||
date = date.getTime();
|
||||
}
|
||||
|
||||
const seconds = Math.round((new Date().getTime() - date) / 1000);
|
||||
if (seconds < -30) {
|
||||
return l10n.t('in {0}', fromNow(new Date().getTime() + seconds * 1000, false));
|
||||
}
|
||||
|
||||
if (!disallowNow && seconds < 30) {
|
||||
return l10n.t('now');
|
||||
}
|
||||
|
||||
let value: number;
|
||||
if (seconds < minute) {
|
||||
value = seconds;
|
||||
|
||||
if (appendAgoLabel) {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} second ago', value)
|
||||
: l10n.t('{0} sec ago', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} seconds ago', value)
|
||||
: l10n.t('{0} secs ago', value);
|
||||
}
|
||||
} else {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} second', value)
|
||||
: l10n.t('{0} sec', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} seconds', value)
|
||||
: l10n.t('{0} secs', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seconds < hour) {
|
||||
value = Math.floor(seconds / minute);
|
||||
if (appendAgoLabel) {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} minute ago', value)
|
||||
: l10n.t('{0} min ago', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} minutes ago', value)
|
||||
: l10n.t('{0} mins ago', value);
|
||||
}
|
||||
} else {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} minute', value)
|
||||
: l10n.t('{0} min', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} minutes', value)
|
||||
: l10n.t('{0} mins', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seconds < day) {
|
||||
value = Math.floor(seconds / hour);
|
||||
if (appendAgoLabel) {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} hour ago', value)
|
||||
: l10n.t('{0} hr ago', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} hours ago', value)
|
||||
: l10n.t('{0} hrs ago', value);
|
||||
}
|
||||
} else {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} hour', value)
|
||||
: l10n.t('{0} hr', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} hours', value)
|
||||
: l10n.t('{0} hrs', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seconds < week) {
|
||||
value = Math.floor(seconds / day);
|
||||
if (appendAgoLabel) {
|
||||
return value === 1
|
||||
? l10n.t('{0} day ago', value)
|
||||
: l10n.t('{0} days ago', value);
|
||||
} else {
|
||||
return value === 1
|
||||
? l10n.t('{0} day', value)
|
||||
: l10n.t('{0} days', value);
|
||||
}
|
||||
}
|
||||
|
||||
if (seconds < month) {
|
||||
value = Math.floor(seconds / week);
|
||||
if (appendAgoLabel) {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} week ago', value)
|
||||
: l10n.t('{0} wk ago', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} weeks ago', value)
|
||||
: l10n.t('{0} wks ago', value);
|
||||
}
|
||||
} else {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} week', value)
|
||||
: l10n.t('{0} wk', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} weeks', value)
|
||||
: l10n.t('{0} wks', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seconds < year) {
|
||||
value = Math.floor(seconds / month);
|
||||
if (appendAgoLabel) {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} month ago', value)
|
||||
: l10n.t('{0} mo ago', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} months ago', value)
|
||||
: l10n.t('{0} mos ago', value);
|
||||
}
|
||||
} else {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} month', value)
|
||||
: l10n.t('{0} mo', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} months', value)
|
||||
: l10n.t('{0} mos', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = Math.floor(seconds / year);
|
||||
if (appendAgoLabel) {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} year ago', value)
|
||||
: l10n.t('{0} yr ago', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} years ago', value)
|
||||
: l10n.t('{0} yrs ago', value);
|
||||
}
|
||||
} else {
|
||||
if (value === 1) {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} year', value)
|
||||
: l10n.t('{0} yr', value);
|
||||
} else {
|
||||
return useFullTimeWords
|
||||
? l10n.t('{0} years', value)
|
||||
: l10n.t('{0} yrs', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче