Co-authored-by: Keith Daulton <keith.daulton@gitkraken.com> Co-authored-by: Ramin Tadayon <ramin.tadayon@gitkraken.com>main
@ -0,0 +1,425 @@ | |||
import type { TextEditor } from 'vscode'; | |||
import { window, workspace } from 'vscode'; | |||
import { Commands } from '../constants'; | |||
import type { Container } from '../container'; | |||
import type { PatchRevisionRange } from '../git/models/patch'; | |||
import { shortenRevision } from '../git/models/reference'; | |||
import type { Repository } from '../git/models/repository'; | |||
import type { Draft, LocalDraft } from '../gk/models/drafts'; | |||
import { showPatchesView } from '../plus/drafts/actions'; | |||
import type { Change } from '../plus/webviews/patchDetails/protocol'; | |||
import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; | |||
import { command } from '../system/command'; | |||
import type { CommandContext } from './base'; | |||
import { | |||
ActiveEditorCommand, | |||
Command, | |||
isCommandContextViewNodeHasCommit, | |||
isCommandContextViewNodeHasComparison, | |||
} from './base'; | |||
export interface CreatePatchCommandArgs { | |||
ref1?: string; | |||
ref2?: string; | |||
repoPath?: string; | |||
} | |||
@command() | |||
export class CreatePatchCommand extends Command { | |||
constructor(private readonly container: Container) { | |||
super(Commands.CreatePatch); | |||
} | |||
protected override preExecute(context: CommandContext, args?: CreatePatchCommandArgs) { | |||
if (args == null) { | |||
if (context.type === 'viewItem') { | |||
if (isCommandContextViewNodeHasCommit(context)) { | |||
args = { | |||
repoPath: context.node.commit.repoPath, | |||
ref1: context.node.commit.ref, | |||
}; | |||
} else if (isCommandContextViewNodeHasComparison(context)) { | |||
args = { | |||
repoPath: context.node.uri.fsPath, | |||
ref1: context.node.compareWithRef.ref, | |||
ref2: context.node.compareRef.ref, | |||
}; | |||
} | |||
} | |||
} | |||
return this.execute(args); | |||
} | |||
async execute(args?: CreatePatchCommandArgs) { | |||
let repo; | |||
if (args?.repoPath == null) { | |||
repo = await getRepositoryOrShowPicker('Create Patch'); | |||
} else { | |||
repo = this.container.git.getRepository(args.repoPath); | |||
} | |||
if (repo == null) return undefined; | |||
if (args?.ref1 == null) return; | |||
const diff = await getDiffContents(this.container, repo, args); | |||
if (diff == null) return; | |||
// let repo; | |||
// if (args?.repoPath == null) { | |||
// repo = await getRepositoryOrShowPicker('Create Patch'); | |||
// } else { | |||
// repo = this.container.git.getRepository(args.repoPath); | |||
// } | |||
// if (repo == null) return; | |||
// const diff = await this.container.git.getDiff(repo.uri, args?.ref1 ?? 'HEAD', args?.ref2); | |||
// if (diff == null) return; | |||
const d = await workspace.openTextDocument({ content: diff.contents, language: 'diff' }); | |||
await window.showTextDocument(d); | |||
// const uri = await window.showSaveDialog({ | |||
// filters: { Patches: ['patch'] }, | |||
// saveLabel: 'Create Patch', | |||
// }); | |||
// if (uri == null) return; | |||
// await workspace.fs.writeFile(uri, new TextEncoder().encode(patch.contents)); | |||
} | |||
} | |||
async function getDiffContents( | |||
container: Container, | |||
repository: Repository, | |||
args: CreatePatchCommandArgs, | |||
): Promise<{ contents: string; revision: PatchRevisionRange } | undefined> { | |||
const sha = args.ref1 ?? 'HEAD'; | |||
const diff = await container.git.getDiff(repository.uri, sha, args.ref2); | |||
if (diff == null) return undefined; | |||
return { | |||
contents: diff.contents, | |||
revision: { | |||
baseSha: args.ref2 ?? `${sha}^`, | |||
sha: sha, | |||
}, | |||
}; | |||
} | |||
interface CreateLocalChange { | |||
title?: string; | |||
description?: string; | |||
changes: Change[]; | |||
} | |||
async function createLocalChange( | |||
container: Container, | |||
repository: Repository, | |||
args: CreatePatchCommandArgs, | |||
): Promise<CreateLocalChange | undefined> { | |||
if (args.ref1 == null) return undefined; | |||
const sha = args.ref1 ?? 'HEAD'; | |||
// const [branchName] = await container.git.getCommitBranches(repository.uri, sha); | |||
const change: Change = { | |||
type: 'revision', | |||
repository: { | |||
name: repository.name, | |||
path: repository.path, | |||
uri: repository.uri.toString(), | |||
}, | |||
files: undefined!, | |||
revision: { | |||
sha: sha, | |||
baseSha: args.ref2 ?? `${sha}^`, | |||
// branchName: branchName ?? 'HEAD', | |||
}, | |||
}; | |||
const create: CreateLocalChange = { changes: [change] }; | |||
const commit = await container.git.getCommit(repository.uri, sha); | |||
if (commit == null) return undefined; | |||
const message = commit.message!.trim(); | |||
const index = message.indexOf('\n'); | |||
if (index < 0) { | |||
create.title = message; | |||
} else { | |||
create.title = message.substring(0, index); | |||
create.description = message.substring(index + 1); | |||
} | |||
if (args.ref2 == null) { | |||
change.files = commit.files != null ? [...commit.files] : []; | |||
} else { | |||
const diff = await getDiffContents(container, repository, args); | |||
if (diff == null) return undefined; | |||
const result = await container.git.getDiffFiles(repository.uri, diff.contents); | |||
if (result?.files == null) return; | |||
create.title = `Comparing ${shortenRevision(args.ref2)} with ${shortenRevision(args.ref1)}`; | |||
change.files = result.files; | |||
} | |||
// const change: Change = { | |||
// type: 'commit', | |||
// repository: { | |||
// name: repository.name, | |||
// path: repository.path, | |||
// uri: repository.uri.toString(), | |||
// }, | |||
// files: result.files, | |||
// range: { | |||
// ...range, | |||
// branchName: branchName ?? 'HEAD', | |||
// }, | |||
// }; | |||
return create; | |||
} | |||
@command() | |||
export class CreateCloudPatchCommand extends Command { | |||
constructor(private readonly container: Container) { | |||
super([Commands.CreateCloudPatch, Commands.ShareAsCloudPatch]); | |||
} | |||
protected override preExecute(context: CommandContext, args?: CreatePatchCommandArgs) { | |||
if (args == null) { | |||
if (context.type === 'viewItem') { | |||
if (isCommandContextViewNodeHasCommit(context)) { | |||
args = { | |||
repoPath: context.node.commit.repoPath, | |||
ref1: context.node.commit.ref, | |||
}; | |||
} else if (isCommandContextViewNodeHasComparison(context)) { | |||
args = { | |||
repoPath: context.node.uri.fsPath, | |||
ref1: context.node.compareWithRef.ref, | |||
ref2: context.node.compareRef.ref, | |||
}; | |||
} | |||
} | |||
} | |||
return this.execute(args); | |||
} | |||
async execute(args?: CreatePatchCommandArgs) { | |||
if (args?.repoPath == null) { | |||
return showPatchesView({ mode: 'create' }); | |||
} | |||
const repo = this.container.git.getRepository(args.repoPath); | |||
if (repo == null) { | |||
return showPatchesView({ mode: 'create' }); | |||
} | |||
const create = await createLocalChange(this.container, repo, args); | |||
if (create == null) { | |||
return showPatchesView({ mode: 'create', create: { repositories: [repo] } }); | |||
} | |||
return showPatchesView({ mode: 'create', create: create }); | |||
// let changes: Change[] | undefined; | |||
// if (args?.repoPath != null) { | |||
// const repo = this.container.git.getRepository(args.repoPath); | |||
// if (repo == null) return; | |||
// const diff = await this.container.git.getDiff(repo.uri, args.ref1 ?? 'HEAD', args.ref2); | |||
// if (diff == null) return; | |||
// const result = await this.container.git.getDiffFiles(args.repoPath, diff.contents); | |||
// if (result?.files == null) return; | |||
// const branch = await repo.getBranch(); | |||
// changes = [ | |||
// { | |||
// type: 'commit', | |||
// repository: { | |||
// name: repo.name, | |||
// path: repo.path, | |||
// uri: repo.uri.toString(true), | |||
// }, | |||
// files: result.files, | |||
// range: { | |||
// baseSha: args.ref2 ?? `${args.ref1 ?? 'HEAD'}^`, | |||
// branchName: branch?.name ?? 'HEAD', | |||
// sha: args.ref1 ?? 'HEAD', | |||
// }, | |||
// }, | |||
// ]; | |||
// } | |||
// let repo; | |||
// if (args?.repoPath == null) { | |||
// repo = await getRepositoryOrShowPicker('Create Cloud Patch'); | |||
// } else { | |||
// repo = this.container.git.getRepository(args.repoPath); | |||
// } | |||
// if (repo == null) return; | |||
// const diff = await this.container.git.getDiff(repo.uri, args?.ref1 ?? 'HEAD', args?.ref2); | |||
// if (diff == null) return; | |||
// const d = await workspace.openTextDocument({ content: diff.contents, language: 'diff' }); | |||
// await window.showTextDocument(d); | |||
// // ask the user for a title | |||
// const title = await window.showInputBox({ | |||
// title: 'Create Cloud Patch', | |||
// prompt: 'Enter a title for the patch', | |||
// validateInput: value => (value == null || value.length === 0 ? 'A title is required' : undefined), | |||
// }); | |||
// if (title == null) return; | |||
// // ask the user for an optional description | |||
// const description = await window.showInputBox({ | |||
// title: 'Create Cloud Patch', | |||
// prompt: 'Enter an optional description for the patch', | |||
// }); | |||
// const patch = await this.container.drafts.createDraft( | |||
// 'patch', | |||
// title, | |||
// { | |||
// contents: diff.contents, | |||
// baseSha: diff.baseSha, | |||
// repository: repo, | |||
// }, | |||
// { description: description }, | |||
// ); | |||
// void this.showPatchNotification(patch); | |||
} | |||
// private async showPatchNotification(patch: Draft | undefined) { | |||
// if (patch == null) return; | |||
// await env.clipboard.writeText(patch.deepLinkUrl); | |||
// const copy = { title: 'Copy Link' }; | |||
// const result = await window.showInformationMessage(`Created cloud patch ${patch.id}`, copy); | |||
// if (result === copy) { | |||
// await env.clipboard.writeText(patch.deepLinkUrl); | |||
// } | |||
// } | |||
} | |||
@command() | |||
export class OpenPatchCommand extends ActiveEditorCommand { | |||
constructor(private readonly container: Container) { | |||
super(Commands.OpenPatch); | |||
} | |||
async execute(editor?: TextEditor) { | |||
let document; | |||
if (editor?.document?.languageId === 'diff') { | |||
document = editor.document; | |||
} else { | |||
const uris = await window.showOpenDialog({ | |||
canSelectFiles: true, | |||
canSelectFolders: false, | |||
canSelectMany: false, | |||
filters: { Patches: ['diff', 'patch'] }, | |||
openLabel: 'Open Patch', | |||
title: 'Open Patch File', | |||
}); | |||
const uri = uris?.[0]; | |||
if (uri == null) return; | |||
document = await workspace.openTextDocument(uri); | |||
await window.showTextDocument(document); | |||
} | |||
const patch: LocalDraft = { | |||
draftType: 'local', | |||
patch: { | |||
type: 'local', | |||
uri: document.uri, | |||
contents: document.getText(), | |||
}, | |||
}; | |||
void showPatchesView({ mode: 'view', draft: patch }); | |||
} | |||
} | |||
export interface OpenCloudPatchCommandArgs { | |||
id: string; | |||
patchId?: string; | |||
draft?: Draft; | |||
} | |||
@command() | |||
export class OpenCloudPatchCommand extends Command { | |||
constructor(private readonly container: Container) { | |||
super(Commands.OpenCloudPatch); | |||
} | |||
async execute(args?: OpenCloudPatchCommandArgs) { | |||
if (args?.id == null && args?.draft == null) { | |||
void window.showErrorMessage('Cannot open cloud patch: no patch or patch id provided'); | |||
return; | |||
} | |||
const draft = args?.draft ?? (await this.container.drafts.getDraft(args?.id)); | |||
if (draft == null) { | |||
void window.showErrorMessage(`Cannot open cloud patch: patch ${args.id} not found`); | |||
return; | |||
} | |||
// let patch: DraftPatch | undefined; | |||
// if (args?.patchId) { | |||
// patch = await this.container.drafts.getPatch(args.patchId); | |||
// } else { | |||
// const patches = draft.changesets?.[0]?.patches; | |||
// if (patches == null || patches.length === 0) { | |||
// void window.showErrorMessage(`Cannot open cloud patch: no patch found under id ${args.patchId}`); | |||
// return; | |||
// } | |||
// patch = patches[0]; | |||
// if (patch.repo == null && patch.repoData != null) { | |||
// const repo = await this.container.git.findMatchingRepository({ | |||
// firstSha: patch.repoData.initialCommitSha, | |||
// remoteUrl: patch.repoData.remote?.url, | |||
// }); | |||
// if (repo != null) { | |||
// patch.repo = repo; | |||
// } | |||
// } | |||
// if (patch.repo == null) { | |||
// void window.showErrorMessage(`Cannot open cloud patch: no repository found for patch ${args.patchId}`); | |||
// return; | |||
// } | |||
// // Opens the patch repository if it's not already open | |||
// void this.container.git.getOrOpenRepository(patch.repo.uri); | |||
// const patchContents = await this.container.drafts.getPatchContents(patch.id); | |||
// if (patchContents == null) { | |||
// void window.showErrorMessage(`Cannot open cloud patch: patch not found of contents empty`); | |||
// return; | |||
// } | |||
// patch.contents = patchContents; | |||
// } | |||
// if (patch == null) { | |||
// void window.showErrorMessage(`Cannot open cloud patch: patch not found`); | |||
// return; | |||
// } | |||
void showPatchesView({ mode: 'view', draft: draft }); | |||
} | |||
} |
@ -0,0 +1,27 @@ | |||
import type { Uri } from 'vscode'; | |||
import type { GitCommit } from './commit'; | |||
import type { GitDiffFiles } from './diff'; | |||
import type { Repository } from './repository'; | |||
/** | |||
* For a single commit `sha` is the commit SHA and `baseSha` is its parent `<sha>^` | |||
* For a commit range `sha` is the tip SHA and `baseSha` is the base SHA | |||
* For a WIP `sha` is the "uncommitted" SHA and `baseSha` is the current HEAD SHA | |||
*/ | |||
export interface PatchRevisionRange { | |||
baseSha: string; | |||
sha: string; | |||
} | |||
export interface GitPatch { | |||
readonly type: 'local'; | |||
readonly contents: string; | |||
readonly id?: undefined; | |||
readonly uri?: Uri; | |||
baseRef?: string; | |||
commit?: GitCommit; | |||
files?: GitDiffFiles['files']; | |||
repository?: Repository; | |||
} |
@ -0,0 +1,218 @@ | |||
import type { GitCommit } from '../../git/models/commit'; | |||
import type { GitFileChangeShape } from '../../git/models/file'; | |||
import type { GitPatch, PatchRevisionRange } from '../../git/models/patch'; | |||
import type { Repository } from '../../git/models/repository'; | |||
import type { GitUser } from '../../git/models/user'; | |||
import type { GkRepositoryId, RepositoryIdentity, RepositoryIdentityRequest } from './repositoryIdentities'; | |||
export interface LocalDraft { | |||
readonly draftType: 'local'; | |||
patch: GitPatch; | |||
} | |||
export interface Draft { | |||
readonly draftType: 'cloud'; | |||
readonly type: 'patch' | 'stash'; | |||
readonly id: string; | |||
readonly createdAt: Date; | |||
readonly updatedAt: Date; | |||
readonly author: { | |||
id: string; | |||
name: string; | |||
email: string | undefined; | |||
avatar?: string; | |||
}; | |||
readonly organizationId?: string; | |||
readonly isPublished: boolean; | |||
readonly title: string; | |||
readonly description?: string; | |||
readonly deepLinkUrl: string; | |||
readonly deepLinkAccess: 'public' | 'private'; | |||
readonly latestChangesetId: string; | |||
changesets?: DraftChangeset[]; | |||
// readonly user?: { | |||
// readonly id: string; | |||
// readonly name: string; | |||
// readonly email: string; | |||
// }; | |||
} | |||
export interface DraftChangeset { | |||
readonly id: string; | |||
readonly createdAt: Date; | |||
readonly updatedAt: Date; | |||
readonly draftId: string; | |||
readonly parentChangesetId: string | undefined; | |||
readonly userId: string; | |||
readonly gitUserName: string; | |||
readonly gitUserEmail: string; | |||
readonly deepLinkUrl?: string; | |||
readonly patches: DraftPatch[]; | |||
} | |||
export interface DraftPatch { | |||
readonly type: 'cloud'; | |||
readonly id: string; | |||
readonly createdAt: Date; | |||
readonly updatedAt: Date; | |||
readonly draftId: string; | |||
readonly changesetId: string; | |||
readonly userId: string; | |||
readonly baseBranchName: string; | |||
/*readonly*/ baseRef: string; | |||
readonly gkRepositoryId: GkRepositoryId; | |||
// repoData?: GitRepositoryData; | |||
readonly secureLink: DraftPatchResponse['secureDownloadData']; | |||
commit?: GitCommit; | |||
contents?: string; | |||
files?: DraftPatchFileChange[]; | |||
repository?: Repository | RepositoryIdentity; | |||
} | |||
export interface DraftPatchDetails { | |||
id: string; | |||
contents: string; | |||
files: DraftPatchFileChange[]; | |||
repository: Repository | RepositoryIdentity; | |||
} | |||
export interface DraftPatchFileChange extends GitFileChangeShape { | |||
readonly gkRepositoryId: GkRepositoryId; | |||
} | |||
export interface CreateDraftChange { | |||
revision: PatchRevisionRange; | |||
contents?: string; | |||
repository: Repository; | |||
} | |||
export interface CreateDraftPatchRequestFromChange { | |||
contents: string; | |||
patch: DraftPatchCreateRequest; | |||
repository: Repository; | |||
user: GitUser | undefined; | |||
} | |||
export interface CreateDraftRequest { | |||
type: 'patch' | 'stash'; | |||
title: string; | |||
description?: string; | |||
isPublic: boolean; | |||
organizationId?: string; | |||
} | |||
export interface CreateDraftResponse { | |||
id: string; | |||
deepLink: string; | |||
} | |||
export interface DraftResponse { | |||
readonly type: 'patch' | 'stash'; | |||
readonly id: string; | |||
readonly createdAt: string; | |||
readonly updatedAt: string; | |||
readonly createdBy: string; | |||
readonly organizationId?: string; | |||
readonly deepLink: string; | |||
readonly isPublic: boolean; | |||
readonly isPublished: boolean; | |||
readonly latestChangesetId: string; | |||
readonly title: string; | |||
readonly description?: string; | |||
} | |||
export interface DraftChangesetCreateRequest { | |||
parentChangesetId?: string | null; | |||
gitUserName?: string; | |||
gitUserEmail?: string; | |||
patches: DraftPatchCreateRequest[]; | |||
} | |||
export interface DraftChangesetCreateResponse { | |||
readonly id: string; | |||
readonly createdAt: string; | |||
readonly updatedAt: string; | |||
readonly draftId: string; | |||
readonly parentChangesetId: string | undefined; | |||
readonly userId: string; | |||
readonly gitUserName: string; | |||
readonly gitUserEmail: string; | |||
readonly deepLink?: string; | |||
readonly patches: DraftPatchCreateResponse[]; | |||
} | |||
export interface DraftChangesetResponse { | |||
readonly id: string; | |||
readonly createdAt: string; | |||
readonly updatedAt: string; | |||
readonly draftId: string; | |||
readonly parentChangesetId: string | undefined; | |||
readonly userId: string; | |||
readonly gitUserName: string; | |||
readonly gitUserEmail: string; | |||
readonly deepLink?: string; | |||
readonly patches: DraftPatchResponse[]; | |||
} | |||
export interface DraftPatchCreateRequest { | |||
baseCommitSha: string; | |||
baseBranchName: string; | |||
gitRepoData: RepositoryIdentityRequest; | |||
} | |||
export interface DraftPatchCreateResponse { | |||
readonly id: string; | |||
readonly createdAt: string; | |||
readonly updatedAt: string; | |||
readonly draftId: string; | |||
readonly changesetId: string; | |||
readonly userId: string; | |||
readonly baseCommitSha: string; | |||
readonly baseBranchName: string; | |||
readonly gitRepositoryId: GkRepositoryId; | |||
readonly secureUploadData: { | |||
readonly headers: { | |||
readonly Host: string[]; | |||
}; | |||
readonly method: string; | |||
readonly url: string; | |||
}; | |||
} | |||
export interface DraftPatchResponse { | |||
readonly id: string; | |||
readonly createdAt: string; | |||
readonly updatedAt: string; | |||
readonly draftId: string; | |||
readonly changesetId: string; | |||
readonly userId: string; | |||
readonly baseCommitSha: string; | |||
readonly baseBranchName: string; | |||
readonly gitRepositoryId: GkRepositoryId; | |||
readonly secureDownloadData: { | |||
readonly headers: { | |||
readonly Host: string[]; | |||
}; | |||
readonly method: string; | |||
readonly url: string; | |||
}; | |||
} |
@ -0,0 +1,83 @@ | |||
import type { Branded } from '../../system/brand'; | |||
export const missingRepositoryId = '-'; | |||
export type GkProviderId = Branded< | |||
'github' | 'githubEnterprise' | 'gitlab' | 'gitlabSelfHosted' | 'bitbucket' | 'bitbucketServer' | 'azureDevops', | |||
'GkProviderId' | |||
>; | |||
export type GkRepositoryId = Branded<string, 'GkRepositoryId'>; | |||
export interface RepositoryIdentity { | |||
readonly id: GkRepositoryId; | |||
readonly createdAt: Date; | |||
readonly updatedAt: Date; | |||
readonly name: string; | |||
readonly initialCommitSha?: string; | |||
readonly remote?: { | |||
readonly url?: string; | |||
readonly domain?: string; | |||
readonly path?: string; | |||
}; | |||
readonly provider?: { | |||
readonly id?: GkProviderId; | |||
readonly repoDomain?: string; | |||
readonly repoName?: string; | |||
readonly repoOwnerDomain?: string; | |||
}; | |||
} | |||
type BaseRepositoryIdentityRequest = { | |||
// name: string; | |||
initialCommitSha?: string; | |||
}; | |||
type BaseRepositoryIdentityRequestWithCommitSha = BaseRepositoryIdentityRequest & { | |||
initialCommitSha: string; | |||
}; | |||
type BaseRepositoryIdentityRequestWithRemote = BaseRepositoryIdentityRequest & { | |||
remote: { url: string; domain: string; path: string }; | |||
}; | |||
type BaseRepositoryIdentityRequestWithRemoteProvider = BaseRepositoryIdentityRequestWithRemote & { | |||
provider: { | |||
id: GkProviderId; | |||
repoDomain: string; | |||
repoName: string; | |||
repoOwnerDomain?: string; | |||
}; | |||
}; | |||
type BaseRepositoryIdentityRequestWithoutRemoteProvider = BaseRepositoryIdentityRequestWithRemote & { | |||
provider?: never; | |||
}; | |||
export type RepositoryIdentityRequest = | |||
| BaseRepositoryIdentityRequestWithCommitSha | |||
| BaseRepositoryIdentityRequestWithRemote | |||
| BaseRepositoryIdentityRequestWithRemoteProvider | |||
| BaseRepositoryIdentityRequestWithoutRemoteProvider; | |||
export interface RepositoryIdentityResponse { | |||
readonly id: GkRepositoryId; | |||
readonly createdAt: string; | |||
readonly updatedAt: string; | |||
// readonly name: string; | |||
readonly initialCommitSha?: string; | |||
readonly remote?: { | |||
readonly url?: string; | |||
readonly domain?: string; | |||
readonly path?: string; | |||
}; | |||
readonly provider?: { | |||
readonly id?: GkProviderId; | |||
readonly repoDomain?: string; | |||
readonly repoName?: string; | |||
readonly repoOwnerDomain?: string; | |||
}; | |||
} |
@ -0,0 +1,12 @@ | |||
import { Container } from '../../container'; | |||
import type { WebviewViewShowOptions } from '../../webviews/webviewsController'; | |||
import type { ShowCreateDraft, ShowViewDraft } from '../webviews/patchDetails/registration'; | |||
type ShowCreateOrOpen = ShowCreateDraft | ShowViewDraft; | |||
export function showPatchesView(createOrOpen: ShowCreateOrOpen, options?: WebviewViewShowOptions): Promise<void> { | |||
if (createOrOpen.mode === 'create') { | |||
options = { ...options, preserveFocus: false, preserveVisibility: false }; | |||
} | |||
return Container.instance.patchDetailsView.show(options, createOrOpen); | |||
} |
@ -0,0 +1,520 @@ | |||
import type { Disposable } from 'vscode'; | |||
import type { Container } from '../../container'; | |||
import { isSha, isUncommitted } from '../../git/models/reference'; | |||
import { isRepository } from '../../git/models/repository'; | |||
import type { GitUser } from '../../git/models/user'; | |||
import type { | |||
CreateDraftChange, | |||
CreateDraftPatchRequestFromChange, | |||
CreateDraftRequest, | |||
CreateDraftResponse, | |||
Draft, | |||
DraftChangeset, | |||
DraftChangesetCreateRequest, | |||
DraftChangesetCreateResponse, | |||
DraftChangesetResponse, | |||
DraftPatch, | |||
DraftPatchDetails, | |||
DraftPatchResponse, | |||
DraftResponse, | |||
} from '../../gk/models/drafts'; | |||
import type { RepositoryIdentityRequest } from '../../gk/models/repositoryIdentities'; | |||
import { log } from '../../system/decorators/log'; | |||
import { Logger } from '../../system/logger'; | |||
import { getLogScope } from '../../system/logger.scope'; | |||
import { getSettledValue } from '../../system/promise'; | |||
import type { ServerConnection } from '../gk/serverConnection'; | |||
export class DraftService implements Disposable { | |||
constructor( | |||
private readonly container: Container, | |||
private readonly connection: ServerConnection, | |||
) {} | |||
dispose(): void {} | |||
@log({ args: { 2: false } }) | |||
async createDraft( | |||
type: 'patch' | 'stash', | |||
title: string, | |||
changes: CreateDraftChange[], | |||
options?: { description?: string; organizationId?: string }, | |||
): Promise<Draft> { | |||
const scope = getLogScope(); | |||
try { | |||
const results = await Promise.allSettled(changes.map(c => this.getCreateDraftPatchRequestFromChange(c))); | |||
if (!results.length) throw new Error('No changes found'); | |||
const patchRequests: CreateDraftPatchRequestFromChange[] = []; | |||
const failed: Error[] = []; | |||
let user: GitUser | undefined; | |||
for (const r of results) { | |||
if (r.status === 'fulfilled') { | |||
// Don't include empty patches -- happens when there are changes in a range that undo each other | |||
if (r.value.contents) { | |||
patchRequests.push(r.value); | |||
if (user == null) { | |||
user = r.value.user; | |||
} | |||
} | |||
} else { | |||
failed.push(r.reason); | |||
} | |||
} | |||
if (failed.length) { | |||
debugger; | |||
throw new AggregateError(failed, 'Unable to create draft'); | |||
} | |||
type DraftResult = { data: CreateDraftResponse }; | |||
// POST v1/drafts | |||
const createDraftRsp = await this.connection.fetchGkDevApi('v1/drafts', { | |||
method: 'POST', | |||
body: JSON.stringify({ | |||
type: type, | |||
title: title, | |||
description: options?.description, | |||
isPublic: true /*organizationId: undefined,*/, | |||
} satisfies CreateDraftRequest), | |||
}); | |||
const createDraft = ((await createDraftRsp.json()) as DraftResult).data; | |||
const draftId = createDraft.id; | |||
type ChangesetResult = { data: DraftChangesetCreateResponse }; | |||
// POST /v1/drafts/:draftId/changesets | |||
const createChangesetRsp = await this.connection.fetchGkDevApi(`v1/drafts/${draftId}/changesets`, { | |||
method: 'POST', | |||
body: JSON.stringify({ | |||
// parentChangesetId: null, | |||
gitUserName: user?.name, | |||
gitUserEmail: user?.email, | |||
patches: patchRequests.map(p => p.patch), | |||
} satisfies DraftChangesetCreateRequest), | |||
}); | |||
const createChangeset = ((await createChangesetRsp.json()) as ChangesetResult).data; | |||
const patches: DraftPatch[] = []; | |||
let i = 0; | |||
for (const patch of createChangeset.patches) { | |||
const { url, method, headers } = patch.secureUploadData; | |||
const { contents, repository } = patchRequests[i++]; | |||
if (contents == null) { | |||
debugger; | |||
throw new Error(`No contents found for ${patch.baseCommitSha}`); | |||
} | |||
// Upload patch to returned S3 url | |||
await this.connection.fetchRaw(url, { | |||
method: method, | |||
headers: { | |||
'Content-Type': 'plain/text', | |||
Host: headers?.['Host']?.['0'] ?? '', | |||
}, | |||
body: contents, | |||
}); | |||
patches.push({ | |||
type: 'cloud', | |||
id: patch.id, | |||
createdAt: new Date(patch.createdAt), | |||
updatedAt: new Date(patch.updatedAt ?? patch.createdAt), | |||
draftId: patch.draftId, | |||
changesetId: patch.changesetId, | |||
userId: createChangeset.userId, | |||
baseBranchName: patch.baseBranchName, | |||
baseRef: patch.baseCommitSha, | |||
gkRepositoryId: patch.gitRepositoryId, | |||
secureLink: undefined!, // patch.secureDownloadData, | |||
contents: contents, | |||
repository: repository, | |||
}); | |||
} | |||
// POST /v1/drafts/:draftId/publish | |||
const publishRsp = await this.connection.fetchGkDevApi(`v1/drafts/${draftId}/publish`, { method: 'POST' }); | |||
if (!publishRsp.ok) throw new Error(`Failed to publish draft: ${publishRsp.statusText}`); | |||
type Result = { data: DraftResponse }; | |||
const draftRsp = await this.connection.fetchGkDevApi(`v1/drafts/${draftId}`, { method: 'GET' }); | |||
const draft = ((await draftRsp.json()) as Result).data; | |||
const author: Draft['author'] = { | |||
id: draft.createdBy, | |||
name: undefined!, | |||
email: undefined, | |||
}; | |||
const { account } = await this.container.subscription.getSubscription(); | |||
if (draft.createdBy === account?.id) { | |||
author.name = `${account.name} (you)`; | |||
author.email = account.email; | |||
} | |||
return { | |||
draftType: 'cloud', | |||
type: draft.type, | |||
id: draftId, | |||
createdAt: new Date(draft.createdAt), | |||
updatedAt: new Date(draft.updatedAt ?? draft.createdAt), | |||
author: author, | |||
organizationId: draft.organizationId || undefined, | |||
isPublished: draft.isPublished, | |||
title: draft.title, | |||
description: draft.description, | |||
deepLinkUrl: createDraft.deepLink, | |||
deepLinkAccess: draft.isPublic ? 'public' : 'private', | |||
latestChangesetId: draft.latestChangesetId, | |||
changesets: [ | |||
{ | |||
id: createChangeset.id, | |||
createdAt: new Date(createChangeset.createdAt), | |||
updatedAt: new Date(createChangeset.updatedAt ?? createChangeset.createdAt), | |||
draftId: createChangeset.draftId, | |||
parentChangesetId: createChangeset.parentChangesetId, | |||
userId: createChangeset.userId, | |||
gitUserName: createChangeset.gitUserName, | |||
gitUserEmail: createChangeset.gitUserEmail, | |||
deepLinkUrl: createChangeset.deepLink, | |||
patches: patches, | |||
}, | |||
], | |||
} satisfies Draft; | |||
} catch (ex) { | |||
debugger; | |||
Logger.error(ex, scope); | |||
throw ex; | |||
} | |||
} | |||
private async getCreateDraftPatchRequestFromChange( | |||
change: CreateDraftChange, | |||
): Promise<CreateDraftPatchRequestFromChange> { | |||
const [branchNamesResult, diffResult, firstShaResult, remoteResult, userResult] = await Promise.allSettled([ | |||
isUncommitted(change.revision.sha) | |||
? this.container.git.getBranch(change.repository.uri).then(b => (b != null ? [b.name] : undefined)) | |||
: this.container.git.getCommitBranches(change.repository.uri, change.revision.sha), | |||
change.contents == null | |||
? this.container.git.getDiff(change.repository.path, change.revision.sha, change.revision.baseSha) | |||
: undefined, | |||
this.container.git.getFirstCommitSha(change.repository.uri), | |||
this.container.git.getBestRemoteWithProvider(change.repository.uri), | |||
this.container.git.getCurrentUser(change.repository.uri), | |||
]); | |||
const firstSha = getSettledValue(firstShaResult); | |||
// TODO: what happens if there are multiple remotes -- which one should we use? Do we need to ask? See more notes below | |||
const remote = getSettledValue(remoteResult); | |||
let repoData: RepositoryIdentityRequest; | |||
if (remote == null) { | |||
if (firstSha == null) throw new Error('No remote or initial commit found'); | |||
repoData = { | |||
initialCommitSha: firstSha, | |||
}; | |||
} else { | |||
repoData = { | |||
initialCommitSha: firstSha, | |||
remote: { | |||
url: remote.url, | |||
domain: remote.domain, | |||
path: remote.path, | |||
}, | |||
provider: | |||
remote.provider.gkProviderId != null | |||
? { | |||
id: remote.provider.gkProviderId, | |||
repoDomain: remote.provider.domain, | |||
repoName: remote.provider.path, | |||
// repoOwnerDomain: ?? | |||
} | |||
: undefined, | |||
}; | |||
} | |||
const diff = getSettledValue(diffResult); | |||
const contents = change.contents ?? diff?.contents; | |||
if (contents == null) throw new Error(`Unable to diff ${change.revision.baseSha} and ${change.revision.sha}`); | |||
const user = getSettledValue(userResult); | |||
const branchNames = getSettledValue(branchNamesResult); | |||
const branchName = branchNames?.[0] ?? ''; | |||
let baseSha = change.revision.baseSha; | |||
if (!isSha(baseSha)) { | |||
const commit = await this.container.git.getCommit(change.repository.uri, baseSha); | |||
if (commit != null) { | |||
baseSha = commit.sha; | |||
} else { | |||
debugger; | |||
} | |||
} | |||
return { | |||
patch: { | |||
baseCommitSha: baseSha, | |||
baseBranchName: branchName, | |||
gitRepoData: repoData, | |||
}, | |||
contents: contents, | |||
repository: change.repository, | |||
user: user, | |||
}; | |||
} | |||
@log() | |||
async deleteDraft(id: string): Promise<void> { | |||
await this.connection.fetchGkDevApi(`v1/drafts/${id}`, { method: 'DELETE' }); | |||
} | |||
@log() | |||
async getDraft(id: string): Promise<Draft> { | |||
type Result = { data: DraftResponse }; | |||
const [rspResult, changesetsResult] = await Promise.allSettled([ | |||
this.connection.fetchGkDevApi(`v1/drafts/${id}`, { method: 'GET' }), | |||
this.getChangesets(id), | |||
]); | |||
const rsp = getSettledValue(rspResult); | |||
if (rsp?.ok === false) { | |||
Logger.error(undefined, `Getting draft failed: (${rsp.status}) ${rsp.statusText}`); | |||
throw new Error(rsp.statusText); | |||
} | |||
const draft = ((await rsp.json()) as Result).data; | |||
const changesets = getSettledValue(changesetsResult) ?? []; | |||
const author: Draft['author'] = { | |||
id: draft.createdBy, | |||
name: undefined!, | |||
email: undefined, | |||
}; | |||
const { account } = await this.container.subscription.getSubscription(); | |||
if (draft.createdBy === account?.id) { | |||
author.name = `${account.name} (you)`; | |||
author.email = account.email; | |||
} | |||
return { | |||
draftType: 'cloud', | |||
type: draft.type, | |||
id: draft.id, | |||
createdAt: new Date(draft.createdAt), | |||
updatedAt: new Date(draft.updatedAt ?? draft.createdAt), | |||
author: author, | |||
organizationId: draft.organizationId || undefined, | |||
isPublished: draft.isPublished, | |||
title: draft.title, | |||
description: draft.description, | |||
deepLinkUrl: draft.deepLink, | |||
deepLinkAccess: draft.isPublic ? 'public' : 'private', | |||
latestChangesetId: draft.latestChangesetId, | |||
changesets: changesets, | |||
}; | |||
} | |||
@log() | |||
async getDrafts(): Promise<Draft[]> { | |||
type Result = { data: DraftResponse[] }; | |||
const rsp = await this.connection.fetchGkDevApi('/v1/drafts', { method: 'GET' }); | |||
const draft = ((await rsp.json()) as Result).data; | |||
const { account } = await this.container.subscription.getSubscription(); | |||
return draft.map( | |||
(d): Draft => ({ | |||
draftType: 'cloud', | |||
type: d.type, | |||
id: d.id, | |||
author: | |||
d.createdBy === account?.id | |||
? { id: d.createdBy, name: `${account.name} (you)`, email: account.email } | |||
: { id: d.createdBy, name: 'Unknown', email: undefined }, | |||
organizationId: d.organizationId || undefined, | |||
isPublished: d.isPublished, | |||
title: d.title, | |||
description: d.description, | |||
deepLinkUrl: d.deepLink, | |||
deepLinkAccess: d.isPublic ? 'public' : 'private', | |||
createdAt: new Date(d.createdAt), | |||
updatedAt: new Date(d.updatedAt ?? d.createdAt), | |||
latestChangesetId: d.latestChangesetId, | |||
}), | |||
); | |||
} | |||
@log() | |||
async getChangesets(id: string): Promise<DraftChangeset[]> { | |||
type Result = { data: DraftChangesetResponse[] }; | |||
const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}/changesets`, { method: 'GET' }); | |||
const changeset = ((await rsp.json()) as Result).data; | |||
const changesets: DraftChangeset[] = []; | |||
for (const c of changeset) { | |||
const patches: DraftPatch[] = []; | |||
// const repoPromises = Promise.allSettled(c.patches.map(p => this.getRepositoryForGkId(p.gitRepositoryId))); | |||
for (const p of c.patches) { | |||
// const repoData = await this.getRepositoryData(p.gitRepositoryId); | |||
// const repo = await this.container.git.findMatchingRepository({ | |||
// firstSha: repoData.initialCommitSha, | |||
// remoteUrl: repoData.remote?.url, | |||
// }); | |||
patches.push({ | |||
type: 'cloud', | |||
id: p.id, | |||
createdAt: new Date(p.createdAt), | |||
updatedAt: new Date(p.updatedAt ?? p.createdAt), | |||
draftId: p.draftId, | |||
changesetId: p.changesetId, | |||
userId: c.userId, | |||
baseBranchName: p.baseBranchName, | |||
baseRef: p.baseCommitSha, | |||
gkRepositoryId: p.gitRepositoryId, | |||
secureLink: p.secureDownloadData, | |||
// // TODO@eamodio FIX THIS | |||
// repository: repo, | |||
// repoData: repoData, | |||
}); | |||
} | |||
changesets.push({ | |||
id: c.id, | |||
createdAt: new Date(c.createdAt), | |||
updatedAt: new Date(c.updatedAt ?? c.createdAt), | |||
draftId: c.draftId, | |||
parentChangesetId: c.parentChangesetId, | |||
userId: c.userId, | |||
gitUserName: c.gitUserName, | |||
gitUserEmail: c.gitUserEmail, | |||
deepLinkUrl: c.deepLink, | |||
patches: patches, | |||
}); | |||
} | |||
return changesets; | |||
} | |||
@log() | |||
async getPatch(id: string): Promise<DraftPatch> { | |||
const patch = await this.getPatchCore(id); | |||
const details = await this.getPatchDetails(patch); | |||
patch.contents = details.contents; | |||
patch.files = details.files; | |||
patch.repository = details.repository; | |||
return patch; | |||
} | |||
private async getPatchCore(id: string): Promise<DraftPatch> { | |||
type Result = { data: DraftPatchResponse }; | |||
// GET /v1/patches/:patchId | |||
const rsp = await this.connection.fetchGkDevApi(`/v1/patches/${id}`, { method: 'GET' }); | |||
const data = ((await rsp.json()) as Result).data; | |||
return { | |||
type: 'cloud', | |||
id: data.id, | |||
createdAt: new Date(data.createdAt), | |||
updatedAt: new Date(data.updatedAt ?? data.createdAt), | |||
draftId: data.draftId, | |||
changesetId: data.changesetId, | |||
userId: data.userId, | |||
baseBranchName: data.baseBranchName, | |||
baseRef: data.baseCommitSha, | |||
gkRepositoryId: data.gitRepositoryId, | |||
secureLink: data.secureDownloadData, | |||
}; | |||
} | |||
async getPatchDetails(id: string): Promise<DraftPatchDetails>; | |||
async getPatchDetails(patch: DraftPatch): Promise<DraftPatchDetails>; | |||
@log<DraftService['getPatchDetails']>({ | |||
args: { 0: idOrPatch => (typeof idOrPatch === 'string' ? idOrPatch : idOrPatch.id) }, | |||
}) | |||
async getPatchDetails(idOrPatch: string | DraftPatch): Promise<DraftPatchDetails> { | |||
const patch = typeof idOrPatch === 'string' ? await this.getPatchCore(idOrPatch) : idOrPatch; | |||
const [contentsResult, repositoryResult] = await Promise.allSettled([ | |||
this.getPatchContentsCore(patch.secureLink), | |||
this.container.repositoryIdentity.getRepositoryOrIdentity(patch.gkRepositoryId), | |||
]); | |||
const contents = getSettledValue(contentsResult)!; | |||
const repositoryOrIdentity = getSettledValue(repositoryResult)!; | |||
let repoPath = ''; | |||
if (isRepository(repositoryOrIdentity)) { | |||
repoPath = repositoryOrIdentity.path; | |||
} | |||
const diffFiles = await this.container.git.getDiffFiles(repoPath, contents); | |||
const files = diffFiles?.files.map(f => ({ ...f, gkRepositoryId: patch.gkRepositoryId })) ?? []; | |||
return { | |||
id: patch.id, | |||
contents: contents, | |||
files: files, | |||
repository: repositoryOrIdentity, | |||
}; | |||
} | |||
private async getPatchContentsCore( | |||
secureLink: DraftPatchResponse['secureDownloadData'], | |||
): Promise<string | undefined> { | |||
const { url, method, headers } = secureLink; | |||
// Download patch from returned S3 url | |||
const contentsRsp = await this.connection.fetchRaw(url, { | |||
method: method, | |||
headers: { | |||
Accept: 'text/plain', | |||
Host: headers?.['Host']?.['0'] ?? '', | |||
}, | |||
}); | |||
return contentsRsp.text(); | |||
} | |||
} |
@ -0,0 +1,163 @@ | |||
import type { Disposable } from 'vscode'; | |||
import { Uri } from 'vscode'; | |||
import type { Container } from '../../container'; | |||
import { shortenRevision } from '../../git/models/reference'; | |||
import type { Repository } from '../../git/models/repository'; | |||
import { parseGitRemoteUrl } from '../../git/parsers/remoteParser'; | |||
import { getRemoteProviderMatcher } from '../../git/remotes/remoteProviders'; | |||
import type { | |||
GkRepositoryId, | |||
RepositoryIdentity, | |||
RepositoryIdentityResponse, | |||
} from '../../gk/models/repositoryIdentities'; | |||
import { missingRepositoryId } from '../../gk/models/repositoryIdentities'; | |||
import { log } from '../../system/decorators/log'; | |||
import type { ServerConnection } from '../gk/serverConnection'; | |||
export class RepositoryIdentityService implements Disposable { | |||
constructor( | |||
private readonly container: Container, | |||
private readonly connection: ServerConnection, | |||
) {} | |||
dispose(): void {} | |||
getRepository( | |||
id: GkRepositoryId, | |||
options?: { openIfNeeded?: boolean; prompt?: boolean }, | |||
): Promise<Repository | undefined>; | |||
getRepository( | |||
identity: RepositoryIdentity, | |||
options?: { openIfNeeded?: boolean; prompt?: boolean }, | |||
): Promise<Repository | undefined>; | |||
@log() | |||
getRepository( | |||
idOrIdentity: GkRepositoryId | RepositoryIdentity, | |||
options?: { openIfNeeded?: boolean }, | |||
): Promise<Repository | undefined> { | |||
return this.locateRepository(idOrIdentity, options); | |||
} | |||
@log() | |||
async getRepositoryOrIdentity( | |||
id: GkRepositoryId, | |||
options?: { openIfNeeded?: boolean; prompt?: boolean }, | |||
): Promise<Repository | RepositoryIdentity> { | |||
const identity = await this.getRepositoryIdentity(id); | |||
return (await this.locateRepository(identity, options)) ?? identity; | |||
} | |||
private async locateRepository( | |||
id: GkRepositoryId, | |||
options?: { openIfNeeded?: boolean; prompt?: boolean }, | |||
): Promise<Repository | undefined>; | |||
private async locateRepository( | |||
identity: RepositoryIdentity, | |||
options?: { openIfNeeded?: boolean; prompt?: boolean }, | |||
): Promise<Repository | undefined>; | |||
private async locateRepository( | |||
idOrIdentity: GkRepositoryId | RepositoryIdentity, | |||
options?: { openIfNeeded?: boolean; prompt?: boolean }, | |||
): Promise<Repository | undefined>; | |||
@log() | |||
private async locateRepository( | |||
idOrIdentity: GkRepositoryId | RepositoryIdentity, | |||
options?: { openIfNeeded?: boolean; prompt?: boolean }, | |||
): Promise<Repository | undefined> { | |||
const identity = | |||
typeof idOrIdentity === 'string' ? await this.getRepositoryIdentity(idOrIdentity) : idOrIdentity; | |||
const matches = await this.container.repositoryPathMapping.getLocalRepoPaths({ | |||
remoteUrl: identity.remote?.url, | |||
repoInfo: | |||
identity.provider != null | |||
? { | |||
provider: identity.provider.id, | |||
owner: identity.provider.repoDomain, | |||
repoName: identity.provider.repoName, | |||
} | |||
: undefined, | |||
}); | |||
let foundRepo: Repository | undefined; | |||
if (matches.length) { | |||
for (const match of matches) { | |||
const repo = this.container.git.getRepository(Uri.file(match)); | |||
if (repo != null) { | |||
foundRepo = repo; | |||
break; | |||
} | |||
} | |||
if (foundRepo == null && options?.openIfNeeded) { | |||
foundRepo = await this.container.git.getOrOpenRepository(Uri.file(matches[0]), { closeOnOpen: true }); | |||
} | |||
} else { | |||
const [, remoteDomain, remotePath] = | |||
identity.remote?.url != null ? parseGitRemoteUrl(identity.remote.url) : []; | |||
// Try to match a repo using the remote URL first, since that saves us some steps. | |||
// As a fallback, try to match using the repo id. | |||
for (const repo of this.container.git.repositories) { | |||
if (remoteDomain != null && remotePath != null) { | |||
const matchingRemotes = await repo.getRemotes({ | |||
filter: r => r.matches(remoteDomain, remotePath), | |||
}); | |||
if (matchingRemotes.length > 0) { | |||
foundRepo = repo; | |||
break; | |||
} | |||
} | |||
if (identity.initialCommitSha != null && identity.initialCommitSha !== missingRepositoryId) { | |||
// Repo ID can be any valid SHA in the repo, though standard practice is to use the | |||
// first commit SHA. | |||
if (await this.container.git.validateReference(repo.uri, identity.initialCommitSha)) { | |||
foundRepo = repo; | |||
break; | |||
} | |||
} | |||
} | |||
} | |||
if (foundRepo == null && options?.prompt) { | |||
// TODO@eamodio prompt the user here if we pass in | |||
} | |||
return foundRepo; | |||
} | |||
@log() | |||
async getRepositoryIdentity(id: GkRepositoryId): Promise<RepositoryIdentity> { | |||
type Result = { data: RepositoryIdentityResponse }; | |||
const rsp = await this.connection.fetchGkDevApi(`/v1/git-repositories/${id}`, { method: 'GET' }); | |||
const data = ((await rsp.json()) as Result).data; | |||
let name: string; | |||
if ('name' in data && typeof data.name === 'string') { | |||
name = data.name; | |||
} else if (data.provider?.repoName != null) { | |||
name = data.provider.repoName; | |||
} else if (data.remote?.url != null && data.remote?.domain != null && data.remote?.path != null) { | |||
const matcher = getRemoteProviderMatcher(this.container); | |||
const provider = matcher(data.remote.url, data.remote.domain, data.remote.path); | |||
name = provider?.repoName ?? data.remote.path; | |||
} else { | |||
name = | |||
data.remote?.path ?? | |||
`Unknown ${data.initialCommitSha ? ` (${shortenRevision(data.initialCommitSha)})` : ''}`; | |||
} | |||
return { | |||
id: data.id, | |||
createdAt: new Date(data.createdAt), | |||
updatedAt: new Date(data.updatedAt), | |||
name: name, | |||
initialCommitSha: data.initialCommitSha, | |||
remote: data.remote, | |||
provider: data.provider, | |||
}; | |||
} | |||
} |
@ -0,0 +1,109 @@ | |||
import { window } from 'vscode'; | |||
import type { Container } from '../container'; | |||
import { isSubscriptionPaidPlan } from './gk/account/subscription'; | |||
export async function ensurePaidPlan(title: string, container: Container): Promise<boolean> { | |||
while (true) { | |||
const subscription = await container.subscription.getSubscription(); | |||
if (subscription.account?.verified === false) { | |||
const resend = { title: 'Resend Verification' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nYou must verify your email before you can continue.`, | |||
{ modal: true }, | |||
resend, | |||
cancel, | |||
); | |||
if (result === resend) { | |||
if (await container.subscription.resendVerification()) { | |||
continue; | |||
} | |||
} | |||
return false; | |||
} | |||
const plan = subscription.plan.effective.id; | |||
if (isSubscriptionPaidPlan(plan)) break; | |||
if (subscription.account == null) { | |||
const signIn = { title: 'Start Free GitKraken Trial' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nTry our developer productivity and collaboration services free for 7 days.`, | |||
{ modal: true }, | |||
signIn, | |||
cancel, | |||
); | |||
if (result === signIn) { | |||
if (await container.subscription.loginOrSignUp()) { | |||
continue; | |||
} | |||
} | |||
} else { | |||
const upgrade = { title: 'Upgrade to Pro' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nContinue to use our developer productivity and collaboration services.`, | |||
{ modal: true }, | |||
upgrade, | |||
cancel, | |||
); | |||
if (result === upgrade) { | |||
void container.subscription.purchase(); | |||
} | |||
} | |||
return false; | |||
} | |||
return true; | |||
} | |||
export async function ensureAccount(title: string, container: Container): Promise<boolean> { | |||
while (true) { | |||
const subscription = await container.subscription.getSubscription(); | |||
if (subscription.account?.verified === false) { | |||
const resend = { title: 'Resend Verification' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nYou must verify your email before you can continue.`, | |||
{ modal: true }, | |||
resend, | |||
cancel, | |||
); | |||
if (result === resend) { | |||
if (await container.subscription.resendVerification()) { | |||
continue; | |||
} | |||
} | |||
return false; | |||
} | |||
if (subscription.account != null) break; | |||
const signIn = { title: 'Sign In / Sign Up' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nGain access to our developer productivity and collaboration services.`, | |||
{ modal: true }, | |||
signIn, | |||
cancel, | |||
); | |||
if (result === signIn) { | |||
if (await container.subscription.loginOrSignUp()) { | |||
continue; | |||
} | |||
} | |||
return false; | |||
} | |||
return true; | |||
} |
@ -0,0 +1,897 @@ | |||
import type { ConfigurationChangeEvent } from 'vscode'; | |||
import { Disposable, env, Uri, window } from 'vscode'; | |||
import type { CoreConfiguration } from '../../../constants'; | |||
import { Commands } from '../../../constants'; | |||
import type { Container } from '../../../container'; | |||
import { openChanges, openChangesWithWorking, openFile } from '../../../git/actions/commit'; | |||
import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; | |||
import type { GitCommit } from '../../../git/models/commit'; | |||
import { uncommitted, uncommittedStaged } from '../../../git/models/constants'; | |||
import { GitFileChange } from '../../../git/models/file'; | |||
import type { PatchRevisionRange } from '../../../git/models/patch'; | |||
import { createReference } from '../../../git/models/reference'; | |||
import { isRepository } from '../../../git/models/repository'; | |||
import type { CreateDraftChange, Draft, DraftPatch, DraftPatchFileChange, LocalDraft } from '../../../gk/models/drafts'; | |||
import type { GkRepositoryId } from '../../../gk/models/repositoryIdentities'; | |||
import { executeCommand, registerCommand } from '../../../system/command'; | |||
import { configuration } from '../../../system/configuration'; | |||
import { setContext } from '../../../system/context'; | |||
import { debug } from '../../../system/decorators/log'; | |||
import type { Deferrable } from '../../../system/function'; | |||
import { debounce } from '../../../system/function'; | |||
import { find, some } from '../../../system/iterable'; | |||
import { basename } from '../../../system/path'; | |||
import type { Serialized } from '../../../system/serialize'; | |||
import { serialize } from '../../../system/serialize'; | |||
import type { IpcMessage } from '../../../webviews/protocol'; | |||
import { onIpc } from '../../../webviews/protocol'; | |||
import type { WebviewController, WebviewProvider } from '../../../webviews/webviewController'; | |||
import type { WebviewShowOptions } from '../../../webviews/webviewsController'; | |||
import { showPatchesView } from '../../drafts/actions'; | |||
import type { ShowInCommitGraphCommandArgs } from '../graph/protocol'; | |||
import type { | |||
ApplyPatchParams, | |||
Change, | |||
CreatePatchParams, | |||
DidExplainParams, | |||
FileActionParams, | |||
Mode, | |||
Preferences, | |||
State, | |||
SwitchModeParams, | |||
UpdateablePreferences, | |||
UpdateCreatePatchMetadataParams, | |||
UpdateCreatePatchRepositoryCheckedStateParams, | |||
} from './protocol'; | |||
import { | |||
ApplyPatchCommandType, | |||
CopyCloudLinkCommandType, | |||
CreatePatchCommandType, | |||
DidChangeCreateNotificationType, | |||
DidChangeDraftNotificationType, | |||
DidChangeNotificationType, | |||
DidChangePreferencesNotificationType, | |||
DidExplainCommandType, | |||
ExplainCommandType, | |||
OpenFileCommandType, | |||
OpenFileComparePreviousCommandType, | |||
OpenFileCompareWorkingCommandType, | |||
OpenInCommitGraphCommandType, | |||
SwitchModeCommandType, | |||
UpdateCreatePatchMetadataCommandType, | |||
UpdateCreatePatchRepositoryCheckedStateCommandType, | |||
UpdatePreferencesCommandType, | |||
} from './protocol'; | |||
import type { CreateDraft, PatchDetailsWebviewShowingArgs } from './registration'; | |||
import type { RepositoryChangeset } from './repositoryChangeset'; | |||
import { RepositoryRefChangeset, RepositoryWipChangeset } from './repositoryChangeset'; | |||
interface Context { | |||
mode: Mode; | |||
draft: LocalDraft | Draft | undefined; | |||
create: | |||
| { | |||
title?: string; | |||
description?: string; | |||
changes: Map<string, RepositoryChangeset>; | |||
showingAllRepos: boolean; | |||
} | |||
| undefined; | |||
preferences: Preferences; | |||
} | |||
export class PatchDetailsWebviewProvider | |||
implements WebviewProvider<State, Serialized<State>, PatchDetailsWebviewShowingArgs> | |||
{ | |||
private _context: Context; | |||
private readonly _disposable: Disposable; | |||
constructor( | |||
private readonly container: Container, | |||
private readonly host: WebviewController<State, Serialized<State>, PatchDetailsWebviewShowingArgs>, | |||
) { | |||
this._context = { | |||
mode: 'create', | |||
draft: undefined, | |||
create: undefined, | |||
preferences: this.getPreferences(), | |||
}; | |||
this.setHostTitle(); | |||
this.host.description = 'PREVIEW ☁️'; | |||
this._disposable = Disposable.from( | |||
configuration.onDidChangeAny(this.onAnyConfigurationChanged, this), | |||
container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), | |||
); | |||
} | |||
dispose() { | |||
this._disposable.dispose(); | |||
} | |||
async onShowing( | |||
_loading: boolean, | |||
options: WebviewShowOptions, | |||
...args: PatchDetailsWebviewShowingArgs | |||
): Promise<boolean> { | |||
const [arg] = args; | |||
if (arg?.mode === 'view' && arg.draft != null) { | |||
this.updateViewDraftState(arg.draft); | |||
} else { | |||
if (this.container.git.isDiscoveringRepositories) { | |||
await this.container.git.isDiscoveringRepositories; | |||
} | |||
const create = arg?.mode === 'create' && arg.create != null ? arg.create : { repositories: undefined }; | |||
this.updateCreateDraftState(create); | |||
} | |||
if (options?.preserveVisibility && !this.host.visible) return false; | |||
return true; | |||
} | |||
includeBootstrap(): Promise<Serialized<State>> { | |||
return this.getState(this._context); | |||
} | |||
registerCommands(): Disposable[] { | |||
return [ | |||
registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true)), | |||
registerCommand(`${this.host.id}.close`, () => this.closeView()), | |||
]; | |||
} | |||
onMessageReceived(e: IpcMessage) { | |||
switch (e.method) { | |||
case ApplyPatchCommandType.method: | |||
onIpc(ApplyPatchCommandType, e, params => this.applyPatch(params)); | |||
break; | |||
case CopyCloudLinkCommandType.method: | |||
onIpc(CopyCloudLinkCommandType, e, () => this.copyCloudLink()); | |||
break; | |||
// case CreateFromLocalPatchCommandType.method: | |||
// onIpc(CreateFromLocalPatchCommandType, e, () => this.shareLocalPatch()); | |||
// break; | |||
case CreatePatchCommandType.method: | |||
onIpc(CreatePatchCommandType, e, params => this.createDraft(params)); | |||
break; | |||
case ExplainCommandType.method: | |||
onIpc(ExplainCommandType, e, () => this.explainPatch(e.completionId)); | |||
break; | |||
case OpenFileComparePreviousCommandType.method: | |||
onIpc( | |||
OpenFileComparePreviousCommandType, | |||
e, | |||
params => void this.openFileComparisonWithPrevious(params), | |||
); | |||
break; | |||
case OpenFileCompareWorkingCommandType.method: | |||
onIpc(OpenFileCompareWorkingCommandType, e, params => void this.openFileComparisonWithWorking(params)); | |||
break; | |||
case OpenFileCommandType.method: | |||
onIpc(OpenFileCommandType, e, params => void this.openFile(params)); | |||
break; | |||
case OpenInCommitGraphCommandType.method: | |||
onIpc( | |||
OpenInCommitGraphCommandType, | |||
e, | |||
params => | |||
void executeCommand<ShowInCommitGraphCommandArgs>(Commands.ShowInCommitGraph, { | |||
ref: createReference(params.ref, params.repoPath, { refType: 'revision' }), | |||
}), | |||
); | |||
break; | |||
// case SelectPatchBaseCommandType.method: | |||
// onIpc(SelectPatchBaseCommandType, e, () => void this.selectPatchBase()); | |||
// break; | |||
// case SelectPatchRepoCommandType.method: | |||
// onIpc(SelectPatchRepoCommandType, e, () => void this.selectPatchRepo()); | |||
// break; | |||
case SwitchModeCommandType.method: | |||
onIpc(SwitchModeCommandType, e, params => this.switchMode(params)); | |||
break; | |||
case UpdateCreatePatchMetadataCommandType.method: | |||
onIpc(UpdateCreatePatchMetadataCommandType, e, params => this.updateCreateMetadata(params)); | |||
break; | |||
case UpdateCreatePatchRepositoryCheckedStateCommandType.method: | |||
onIpc(UpdateCreatePatchRepositoryCheckedStateCommandType, e, params => | |||
this.updateCreateCheckedState(params), | |||
); | |||
break; | |||
case UpdatePreferencesCommandType.method: | |||
onIpc(UpdatePreferencesCommandType, e, params => this.updatePreferences(params)); | |||
break; | |||
} | |||
} | |||
onRefresh(): void { | |||
this.updateState(true); | |||
} | |||
onReloaded(): void { | |||
this.updateState(true); | |||
} | |||
onVisibilityChanged(visible: boolean) { | |||
// TODO@eamodio ugly -- clean this up later | |||
this._context.create?.changes.forEach(c => (visible ? c.resume() : c.suspend())); | |||
if (visible) { | |||
this.host.sendPendingIpcNotifications(); | |||
} | |||
} | |||
private onAnyConfigurationChanged(e: ConfigurationChangeEvent) { | |||
if ( | |||
configuration.changed(e, ['defaultDateFormat', 'views.patchDetails.files', 'views.patchDetails.avatars']) || | |||
configuration.changedAny<CoreConfiguration>(e, 'workbench.tree.renderIndentGuides') || | |||
configuration.changedAny<CoreConfiguration>(e, 'workbench.tree.indent') | |||
) { | |||
this._context.preferences = { ...this._context.preferences, ...this.getPreferences() }; | |||
this.updateState(); | |||
} | |||
} | |||
private getPreferences(): Preferences { | |||
return { | |||
avatars: configuration.get('views.patchDetails.avatars'), | |||
dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma', | |||
files: configuration.get('views.patchDetails.files'), | |||
indentGuides: | |||
configuration.getAny<CoreConfiguration, Preferences['indentGuides']>( | |||
'workbench.tree.renderIndentGuides', | |||
) ?? 'onHover', | |||
indent: configuration.getAny<CoreConfiguration, Preferences['indent']>('workbench.tree.indent'), | |||
}; | |||
} | |||
private onRepositoriesChanged(e: RepositoriesChangeEvent) { | |||
if (this.mode === 'create' && this._context.create != null) { | |||
if (this._context.create?.showingAllRepos) { | |||
for (const repo of e.added) { | |||
this._context.create.changes.set( | |||
repo.uri.toString(), | |||
new RepositoryWipChangeset( | |||
this.container, | |||
repo, | |||
{ baseSha: 'HEAD', sha: uncommitted }, | |||
this.onRepositoryWipChanged.bind(this), | |||
false, | |||
true, | |||
), | |||
); | |||
} | |||
} | |||
for (const repo of e.removed) { | |||
this._context.create.changes.delete(repo.uri.toString()); | |||
} | |||
void this.notifyDidChangeCreateDraftState(); | |||
} | |||
} | |||
private onRepositoryWipChanged(_e: RepositoryWipChangeset) { | |||
void this.notifyDidChangeCreateDraftState(); | |||
} | |||
private get mode(): Mode { | |||
return this._context.mode; | |||
} | |||
private setMode(mode: Mode, silent?: boolean) { | |||
this._context.mode = mode; | |||
this.setHostTitle(mode); | |||
void setContext('gitlens:views:patchDetails:mode', mode); | |||
if (!silent) { | |||
this.updateState(true); | |||
} | |||
} | |||
private setHostTitle(mode: Mode = this._context.mode) { | |||
this.host.title = mode === 'create' ? 'Create Cloud Patch' : 'Cloud Patch Details'; | |||
} | |||
private applyPatch(_params: ApplyPatchParams) { | |||
// if (params.details.repoPath == null || params.details.commit == null) return; | |||
// void this.container.git.applyPatchCommit(params.details.repoPath, params.details.commit, { | |||
// branchName: params.targetRef, | |||
// }); | |||
if (this._context.draft == null) return; | |||
if (this._context.draft.draftType === 'local') return; | |||
const draft = this._context.draft; | |||
const changeset = draft.changesets?.[0]; | |||
if (changeset == null) return; | |||
console.log(changeset); | |||
} | |||
private closeView() { | |||
void setContext('gitlens:views:patchDetails:mode', undefined); | |||
} | |||
private copyCloudLink() { | |||
if (this._context.draft?.draftType !== 'cloud') return; | |||
void env.clipboard.writeText(this._context.draft.deepLinkUrl); | |||
} | |||
private async createDraft({ title, changesets, description }: CreatePatchParams): Promise<void> { | |||
const createChanges: CreateDraftChange[] = []; | |||
const changes = Object.entries(changesets); | |||
const ignoreChecked = changes.length === 1; | |||
for (const [id, change] of changes) { | |||
if (!ignoreChecked && change.checked === false) continue; | |||
const repoChangeset = this._context.create?.changes?.get(id); | |||
if (repoChangeset == null) continue; | |||
let { revision, repository } = repoChangeset; | |||
if (change.type === 'wip' && change.checked === 'staged') { | |||
revision = { ...revision, sha: uncommittedStaged }; | |||
} | |||
createChanges.push({ | |||
repository: repository, | |||
revision: revision, | |||
}); | |||
} | |||
if (createChanges == null) return; | |||
try { | |||
const draft = await this.container.drafts.createDraft( | |||
'patch', | |||
title, | |||
createChanges, | |||
description ? { description: description } : undefined, | |||
); | |||
async function showNotification() { | |||
const view = { title: 'View Patch' }; | |||
const copy = { title: 'Copy Link' }; | |||
while (true) { | |||
const result = await window.showInformationMessage( | |||
'Cloud Patch successfully created \u2014 link copied to the clipboard', | |||
view, | |||
copy, | |||
); | |||
if (result === copy) { | |||
void env.clipboard.writeText(draft.deepLinkUrl); | |||
continue; | |||
} | |||
if (result === view) { | |||
void showPatchesView({ mode: 'view', draft: draft }); | |||
} | |||
break; | |||
} | |||
} | |||
void showNotification(); | |||
void this.container.draftsView.refresh(true).then(() => void this.container.draftsView.revealDraft(draft)); | |||
this.closeView(); | |||
} catch (ex) { | |||
debugger; | |||
void window.showErrorMessage(`Unable to create draft: ${ex.message}`); | |||
} | |||
} | |||
private async explainPatch(completionId?: string) { | |||
if (this._context.draft?.draftType !== 'cloud') return; | |||
let params: DidExplainParams; | |||
try { | |||
// TODO@eamodio HACK -- only works for the first patch | |||
const patch = await this.getDraftPatch(this._context.draft); | |||
if (patch == null) return; | |||
const commit = await this.getOrCreateCommitForPatch(patch.gkRepositoryId); | |||
if (commit == null) return; | |||
const summary = await this.container.ai.explainCommit(commit, { | |||
progress: { location: { viewId: this.host.id } }, | |||
}); | |||
params = { summary: summary }; | |||
} catch (ex) { | |||
debugger; | |||
params = { error: { message: ex.message } }; | |||
} | |||
void this.host.notify(DidExplainCommandType, params, completionId); | |||
} | |||
private async openPatchContents(_params: FileActionParams) { | |||
// TODO@eamodio Open the patch contents for the selected repo in an untitled editor | |||
} | |||
private updateCreateCheckedState(params: UpdateCreatePatchRepositoryCheckedStateParams) { | |||
const changeset = this._context.create?.changes.get(params.repoUri); | |||
if (changeset == null) return; | |||
changeset.checked = params.checked; | |||
void this.notifyDidChangeCreateDraftState(); | |||
} | |||
private updateCreateMetadata(params: UpdateCreatePatchMetadataParams) { | |||
if (this._context.create == null) return; | |||
this._context.create.title = params.title; | |||
this._context.create.description = params.description; | |||
void this.notifyDidChangeCreateDraftState(); | |||
} | |||
// private shareLocalPatch() { | |||
// if (this._context.open?.draftType !== 'local') return; | |||
// this.updateCreateFromLocalPatch(this._context.open); | |||
// } | |||
private switchMode(params: SwitchModeParams) { | |||
this.setMode(params.mode); | |||
} | |||
private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; | |||
private updateState(immediate: boolean = false) { | |||
this.host.clearPendingIpcNotifications(); | |||
if (immediate) { | |||
void this.notifyDidChangeState(); | |||
return; | |||
} | |||
if (this._notifyDidChangeStateDebounced == null) { | |||
this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); | |||
} | |||
this._notifyDidChangeStateDebounced(); | |||
} | |||
@debug({ args: false }) | |||
protected async getState(current: Context): Promise<Serialized<State>> { | |||
let create; | |||
if (current.mode === 'create' && current.create != null) { | |||
create = await this.getCreateDraftState(current); | |||
} | |||
let draft; | |||
if (current.mode === 'view' && current.draft != null) { | |||
draft = await this.getViewDraftState(current); | |||
} | |||
const state = serialize<State>({ | |||
...this.host.baseWebviewState, | |||
mode: current.mode, | |||
create: create, | |||
draft: draft, | |||
preferences: current.preferences, | |||
}); | |||
return state; | |||
} | |||
private async notifyDidChangeState() { | |||
this._notifyDidChangeStateDebounced?.cancel(); | |||
return this.host.notify(DidChangeNotificationType, { state: await this.getState(this._context) }); | |||
} | |||
private updateCreateDraftState(create: CreateDraft) { | |||
let changesetByRepo: Map<string, RepositoryChangeset>; | |||
let allRepos = false; | |||
if (create.changes != null) { | |||
changesetByRepo = new Map<string, RepositoryChangeset>(); | |||
const updated = new Set<string>(); | |||
for (const change of create.changes) { | |||
const repo = this.container.git.getRepository(Uri.parse(change.repository.uri)); | |||
if (repo == null) continue; | |||
let changeset: RepositoryChangeset; | |||
if (change.type === 'wip') { | |||
changeset = new RepositoryWipChangeset( | |||
this.container, | |||
repo, | |||
change.revision, | |||
this.onRepositoryWipChanged.bind(this), | |||
change.checked ?? true, | |||
change.expanded ?? true, | |||
); | |||
} else { | |||
changeset = new RepositoryRefChangeset( | |||
this.container, | |||
repo, | |||
change.revision, | |||
change.files, | |||
change.checked ?? true, | |||
change.expanded ?? true, | |||
); | |||
} | |||
updated.add(repo.uri.toString()); | |||
changesetByRepo.set(repo.uri.toString(), changeset); | |||
} | |||
if (updated.size !== changesetByRepo.size) { | |||
for (const [uri, repoChange] of changesetByRepo) { | |||
if (updated.has(uri)) continue; | |||
repoChange.checked = false; | |||
} | |||
} | |||
} else { | |||
allRepos = create.repositories == null; | |||
const repos = create.repositories ?? this.container.git.openRepositories; | |||
changesetByRepo = new Map( | |||
repos.map(r => [ | |||
r.uri.toString(), | |||
new RepositoryWipChangeset( | |||
this.container, | |||
r, | |||
{ | |||
baseSha: 'HEAD', | |||
sha: uncommitted, | |||
}, | |||
this.onRepositoryWipChanged.bind(this), | |||
false, | |||
true, // TODO revisit | |||
), | |||
]), | |||
); | |||
} | |||
this._context.create = { | |||
title: create.title, | |||
description: create.description, | |||
changes: changesetByRepo, | |||
showingAllRepos: allRepos, | |||
}; | |||
this.setMode('create', true); | |||
void this.notifyDidChangeCreateDraftState(); | |||
} | |||
private async getCreateDraftState(current: Context): Promise<State['create'] | undefined> { | |||
const { create } = current; | |||
if (create == null) return undefined; | |||
const repoChanges: Record<string, Change> = {}; | |||
if (create.changes.size !== 0) { | |||
for (const [id, repo] of create.changes) { | |||
const change = await repo.getChange(); | |||
if (change?.files?.length === 0) continue; // TODO remove when we support dynamic expanded repos | |||
if (change.checked !== repo.checked) { | |||
change.checked = repo.checked; | |||
} | |||
repoChanges[id] = change; | |||
} | |||
} | |||
return { | |||
title: create.title, | |||
description: create.description, | |||
changes: repoChanges, | |||
}; | |||
} | |||
private async notifyDidChangeCreateDraftState() { | |||
return this.host.notify(DidChangeCreateNotificationType, { | |||
mode: this._context.mode, | |||
create: await this.getCreateDraftState(this._context), | |||
}); | |||
} | |||
private updateViewDraftState(draft: LocalDraft | Draft | undefined) { | |||
this._context.draft = draft; | |||
this.setMode('view', true); | |||
void this.notifyDidChangeViewDraftState(); | |||
} | |||
// eslint-disable-next-line @typescript-eslint/require-await | |||
private async getViewDraftState(current: Context): Promise<State['draft'] | undefined> { | |||
if (current.draft == null) return undefined; | |||
const draft = current.draft; | |||
// if (draft.draftType === 'local') { | |||
// const { patch } = draft; | |||
// if (patch.repository == null) { | |||
// const repo = this.container.git.getBestRepository(); | |||
// if (repo != null) { | |||
// patch.repository = repo; | |||
// } | |||
// } | |||
// return { | |||
// draftType: 'local', | |||
// files: patch.files ?? [], | |||
// repoPath: patch.repository?.path, | |||
// repoName: patch.repository?.name, | |||
// baseRef: patch.baseRef, | |||
// }; | |||
// } | |||
if (draft.draftType === 'cloud') { | |||
if ( | |||
draft.changesets == null || | |||
some(draft.changesets, cs => | |||
cs.patches.some(p => p.contents == null || p.files == null || p.repository == null), | |||
) | |||
) { | |||
setTimeout(async () => { | |||
if (draft.changesets == null) { | |||
draft.changesets = await this.container.drafts.getChangesets(draft.id); | |||
} | |||
const patches = draft.changesets | |||
.flatMap(cs => cs.patches) | |||
.filter(p => p.contents == null || p.files == null || p.repository == null); | |||
const patchDetails = await Promise.allSettled( | |||
patches.map(p => this.container.drafts.getPatchDetails(p)), | |||
); | |||
for (const d of patchDetails) { | |||
if (d.status === 'fulfilled') { | |||
const patch = patches.find(p => p.id === d.value.id); | |||
if (patch != null) { | |||
patch.contents = d.value.contents; | |||
patch.files = d.value.files; | |||
patch.repository = d.value.repository; | |||
} | |||
} | |||
} | |||
void this.notifyDidChangeViewDraftState(); | |||
}, 0); | |||
} | |||
return { | |||
draftType: 'cloud', | |||
id: draft.id, | |||
createdAt: draft.createdAt.getTime(), | |||
updatedAt: draft.updatedAt.getTime(), | |||
author: draft.author, | |||
title: draft.title, | |||
description: draft.description, | |||
patches: serialize( | |||
draft.changesets![0].patches.map(p => ({ | |||
...p, | |||
contents: undefined, | |||
commit: undefined, | |||
repository: { | |||
id: p.gkRepositoryId, | |||
name: p.repository?.name ?? '', | |||
}, | |||
})), | |||
), | |||
}; | |||
} | |||
return undefined; | |||
} | |||
private async notifyDidChangeViewDraftState() { | |||
return this.host.notify(DidChangeDraftNotificationType, { | |||
mode: this._context.mode, | |||
draft: serialize(await this.getViewDraftState(this._context)), | |||
}); | |||
} | |||
private updatePreferences(preferences: UpdateablePreferences) { | |||
if ( | |||
this._context.preferences?.files?.compact === preferences.files?.compact && | |||
this._context.preferences?.files?.icon === preferences.files?.icon && | |||
this._context.preferences?.files?.layout === preferences.files?.layout && | |||
this._context.preferences?.files?.threshold === preferences.files?.threshold | |||
) { | |||
return; | |||
} | |||
if (preferences.files != null) { | |||
if (this._context.preferences?.files?.compact !== preferences.files?.compact) { | |||
void configuration.updateEffective('views.patchDetails.files.compact', preferences.files?.compact); | |||
} | |||
if (this._context.preferences?.files?.icon !== preferences.files?.icon) { | |||
void configuration.updateEffective('views.patchDetails.files.icon', preferences.files?.icon); | |||
} | |||
if (this._context.preferences?.files?.layout !== preferences.files?.layout) { | |||
void configuration.updateEffective('views.patchDetails.files.layout', preferences.files?.layout); | |||
} | |||
if (this._context.preferences?.files?.threshold !== preferences.files?.threshold) { | |||
void configuration.updateEffective('views.patchDetails.files.threshold', preferences.files?.threshold); | |||
} | |||
this._context.preferences.files = preferences.files; | |||
} | |||
void this.notifyDidChangePreferences(); | |||
} | |||
private async notifyDidChangePreferences() { | |||
return this.host.notify(DidChangePreferencesNotificationType, { preferences: this._context.preferences }); | |||
} | |||
private async getDraftPatch(draft: Draft, gkRepositoryId?: GkRepositoryId): Promise<DraftPatch | undefined> { | |||
if (draft.changesets == null) { | |||
const changesets = await this.container.drafts.getChangesets(draft.id); | |||
draft.changesets = changesets; | |||
} | |||
const patch = | |||
gkRepositoryId == null | |||
? draft.changesets[0].patches?.[0] | |||
: draft.changesets[0].patches?.find(p => p.gkRepositoryId === gkRepositoryId); | |||
if (patch == null) return undefined; | |||
if (patch.contents == null || patch.files == null || patch.repository == null) { | |||
const details = await this.container.drafts.getPatchDetails(patch.id); | |||
patch.contents = details.contents; | |||
patch.files = details.files; | |||
patch.repository = details.repository; | |||
} | |||
return patch; | |||
} | |||
private async getFileCommitFromParams( | |||
params: FileActionParams, | |||
): Promise< | |||
| [commit: GitCommit, file: GitFileChange, revision?: Required<Omit<PatchRevisionRange, 'branchName'>>] | |||
| undefined | |||
> { | |||
let [commit, revision] = await this.getOrCreateCommit(params); | |||
if (commit != null && revision != null) { | |||
return [ | |||
commit, | |||
new GitFileChange( | |||
params.repoPath, | |||
params.path, | |||
params.status, | |||
params.originalPath, | |||
undefined, | |||
undefined, | |||
params.staged, | |||
), | |||
revision, | |||
]; | |||
} | |||
commit = await commit?.getCommitForFile(params.path, params.staged); | |||
return commit != null ? [commit, commit.file!, revision] : undefined; | |||
} | |||
private async getOrCreateCommit( | |||
file: DraftPatchFileChange, | |||
): Promise<[commit: GitCommit | undefined, revision?: PatchRevisionRange]> { | |||
switch (this.mode) { | |||
case 'create': | |||
return this.getCommitForFile(file); | |||
case 'view': | |||
return [await this.getOrCreateCommitForPatch(file.gkRepositoryId)]; | |||
default: | |||
return [undefined]; | |||
} | |||
} | |||
async getCommitForFile( | |||
file: DraftPatchFileChange, | |||
): Promise<[commit: GitCommit | undefined, revision?: PatchRevisionRange]> { | |||
const changeset = find(this._context.create!.changes.values(), cs => cs.repository.path === file.repoPath); | |||
if (changeset == null) return [undefined]; | |||
const change = await changeset.getChange(); | |||
if (change == null) return [undefined]; | |||
if (change.type === 'revision') { | |||
const commit = await this.container.git.getCommit(file.repoPath, change.revision.sha ?? uncommitted); | |||
if ( | |||
change.revision.sha === change.revision.baseSha || | |||
change.revision.sha === change.revision.baseSha.substring(0, change.revision.baseSha.length - 1) | |||
) { | |||
return [commit]; | |||
} | |||
return [commit, change.revision]; | |||
} else if (change.type === 'wip') { | |||
return [await this.container.git.getCommit(file.repoPath, change.revision.sha ?? uncommitted)]; | |||
} | |||
return [undefined]; | |||
} | |||
async getOrCreateCommitForPatch(gkRepositoryId: GkRepositoryId): Promise<GitCommit | undefined> { | |||
const draft = this._context.draft!; | |||
if (draft.draftType === 'local') return undefined; // TODO | |||
const patch = await this.getDraftPatch(draft, gkRepositoryId); | |||
if (patch?.repository == null) return undefined; | |||
if (patch?.commit == null) { | |||
if (!isRepository(patch.repository)) { | |||
const repo = await this.container.repositoryIdentity.getRepository(patch.repository, { | |||
openIfNeeded: true, | |||
prompt: true, | |||
}); | |||
if (repo == null) return undefined; // TODO | |||
patch.repository = repo; | |||
} | |||
try { | |||
const commit = await this.container.git.createUnreachableCommitForPatch( | |||
patch.repository.uri, | |||
patch.contents!, | |||
patch.baseRef ?? 'HEAD', | |||
draft.title, | |||
); | |||
patch.commit = commit; | |||
} catch (ex) { | |||
void window.showErrorMessage(`Unable preview the patch on base '${patch.baseRef}': ${ex.message}`); | |||
patch.baseRef = undefined!; | |||
} | |||
} | |||
return patch?.commit; | |||
} | |||
private async openFile(params: FileActionParams) { | |||
const result = await this.getFileCommitFromParams(params); | |||
if (result == null) return; | |||
const [commit, file] = result; | |||
void openFile(file, commit, { | |||
preserveFocus: true, | |||
preview: true, | |||
...params.showOptions, | |||
}); | |||
} | |||
private async openFileComparisonWithPrevious(params: FileActionParams) { | |||
const result = await this.getFileCommitFromParams(params); | |||
if (result == null) return; | |||
const [commit, file, revision] = result; | |||
void openChanges( | |||
file, | |||
revision != null | |||
? { repoPath: commit.repoPath, ref1: revision.sha ?? uncommitted, ref2: revision.baseSha } | |||
: commit, | |||
{ | |||
preserveFocus: true, | |||
preview: true, | |||
...params.showOptions, | |||
rhsTitle: this.mode === 'view' ? `${basename(file.path)} (Patch)` : undefined, | |||
}, | |||
); | |||
this.container.events.fire('file:selected', { uri: file.uri }, { source: this.host.id }); | |||
} | |||
private async openFileComparisonWithWorking(params: FileActionParams) { | |||
const result = await this.getFileCommitFromParams(params); | |||
if (result == null) return; | |||
const [commit, file, revision] = result; | |||
void openChangesWithWorking( | |||
file, | |||
revision != null ? { repoPath: commit.repoPath, ref: revision.baseSha } : commit, | |||
{ | |||
preserveFocus: true, | |||
preview: true, | |||
...params.showOptions, | |||
lhsTitle: this.mode === 'view' ? `${basename(file.path)} (Patch)` : undefined, | |||
}, | |||
); | |||
} | |||
} |
@ -0,0 +1,256 @@ | |||
import type { TextDocumentShowOptions } from 'vscode'; | |||
import type { Config } from '../../../config'; | |||
import type { WebviewIds, WebviewViewIds } from '../../../constants'; | |||
import type { GitFileChangeShape } from '../../../git/models/file'; | |||
import type { PatchRevisionRange } from '../../../git/models/patch'; | |||
import type { DraftPatch, DraftPatchFileChange } from '../../../gk/models/drafts'; | |||
import type { GkRepositoryId } from '../../../gk/models/repositoryIdentities'; | |||
import type { DateTimeFormat } from '../../../system/date'; | |||
import type { Serialized } from '../../../system/serialize'; | |||
import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; | |||
export const messageHeadlineSplitterToken = '\x00\n\x00'; | |||
export type FileShowOptions = TextDocumentShowOptions; | |||
type PatchDetails = Serialized< | |||
Omit<DraftPatch, 'commit' | 'contents' | 'repository'> & { repository: { id: GkRepositoryId; name: string } } | |||
>; | |||
interface LocalDraftDetails { | |||
draftType: 'local'; | |||
id?: never; | |||
author?: never; | |||
createdAt?: never; | |||
updatedAt?: never; | |||
title?: string; | |||
description?: string; | |||
patches?: PatchDetails[]; | |||
// files?: GitFileChangeShape[]; | |||
// stats?: GitCommitStats; | |||
// repoPath?: string; | |||
// repoName?: string; | |||
// baseRef?: string; | |||
// commit?: string; | |||
} | |||
interface CloudDraftDetails { | |||
draftType: 'cloud'; | |||
id: string; | |||
createdAt: number; | |||
updatedAt: number; | |||
author: { | |||
id: string; | |||
name: string; | |||
email: string | undefined; | |||
avatar?: string; | |||
}; | |||
title: string; | |||
description?: string; | |||
patches?: PatchDetails[]; | |||
// commit?: string; | |||
// files?: GitFileChangeShape[]; | |||
// stats?: GitCommitStats; | |||
// repoPath: string; | |||
// repoName?: string; | |||
// baseRef?: string; | |||
} | |||
export type DraftDetails = LocalDraftDetails | CloudDraftDetails; | |||
// export interface RangeRef { | |||
// baseSha: string; | |||
// sha: string | undefined; | |||
// branchName: string; | |||
// // shortSha: string; | |||
// // summary: string; | |||
// // message: string; | |||
// // author: GitCommitIdentityShape & { avatar: string | undefined }; | |||
// // committer: GitCommitIdentityShape & { avatar: string | undefined }; | |||
// // parents: string[]; | |||
// // repoPath: string; | |||
// // stashNumber?: string; | |||
// } | |||
export interface Preferences { | |||
avatars: boolean; | |||
dateFormat: DateTimeFormat | string; | |||
files: Config['views']['patchDetails']['files']; | |||
indentGuides: 'none' | 'onHover' | 'always'; | |||
indent: number | undefined; | |||
} | |||
export type UpdateablePreferences = Partial<Pick<Preferences, 'files'>>; | |||
export type Mode = 'create' | 'view'; | |||
export type ChangeType = 'revision' | 'wip'; | |||
export interface WipChange { | |||
type: 'wip'; | |||
repository: { name: string; path: string; uri: string }; | |||
revision: PatchRevisionRange; | |||
files: GitFileChangeShape[] | undefined; | |||
checked?: boolean | 'staged'; | |||
expanded?: boolean; | |||
} | |||
export interface RevisionChange { | |||
type: 'revision'; | |||
repository: { name: string; path: string; uri: string }; | |||
revision: PatchRevisionRange; | |||
files: GitFileChangeShape[]; | |||
checked?: boolean | 'staged'; | |||
expanded?: boolean; | |||
} | |||
export type Change = WipChange | RevisionChange; | |||
// export interface RepoCommitChange { | |||
// type: 'commit'; | |||
// repoName: string; | |||
// repoUri: string; | |||
// change: Change; | |||
// checked: boolean; | |||
// expanded: boolean; | |||
// } | |||
// export interface RepoWipChange { | |||
// type: 'wip'; | |||
// repoName: string; | |||
// repoUri: string; | |||
// change: Change | undefined; | |||
// checked: boolean | 'staged'; | |||
// expanded: boolean; | |||
// } | |||
// export type RepoChangeSet = RepoCommitChange | RepoWipChange; | |||
export interface State { | |||
webviewId: WebviewIds | WebviewViewIds; | |||
timestamp: number; | |||
mode: Mode; | |||
preferences: Preferences; | |||
draft?: DraftDetails; | |||
create?: { | |||
title?: string; | |||
description?: string; | |||
changes: Record<string, Change>; | |||
creationError?: string; | |||
}; | |||
} | |||
export type ShowCommitDetailsViewCommandArgs = string[]; | |||
// COMMANDS | |||
export interface ApplyPatchParams { | |||
details: DraftDetails; | |||
targetRef?: string; // a branch name. default to HEAD if not supplied | |||
selected: PatchDetails['id'][]; | |||
} | |||
export const ApplyPatchCommandType = new IpcCommandType<ApplyPatchParams>('patch/apply'); | |||
export interface CreatePatchParams { | |||
title: string; | |||
description?: string; | |||
changesets: Record<string, Change>; | |||
} | |||
export const CreatePatchCommandType = new IpcCommandType<CreatePatchParams>('patch/create'); | |||
export interface OpenInCommitGraphParams { | |||
repoPath: string; | |||
ref: string; | |||
} | |||
export const OpenInCommitGraphCommandType = new IpcCommandType<OpenInCommitGraphParams>('patch/openInGraph'); | |||
export interface SelectPatchRepoParams { | |||
repoPath: string; | |||
} | |||
export const SelectPatchRepoCommandType = new IpcCommandType<undefined>('patch/selectRepo'); | |||
export const SelectPatchBaseCommandType = new IpcCommandType<undefined>('patch/selectBase'); | |||
export interface FileActionParams extends DraftPatchFileChange { | |||
showOptions?: TextDocumentShowOptions; | |||
} | |||
export const FileActionsCommandType = new IpcCommandType<FileActionParams>('patch/file/actions'); | |||
export const OpenFileCommandType = new IpcCommandType<FileActionParams>('patch/file/open'); | |||
export const OpenFileOnRemoteCommandType = new IpcCommandType<FileActionParams>('patch/file/openOnRemote'); | |||
export const OpenFileCompareWorkingCommandType = new IpcCommandType<FileActionParams>('patch/file/compareWorking'); | |||
export const OpenFileComparePreviousCommandType = new IpcCommandType<FileActionParams>('patch/file/comparePrevious'); | |||
export const ExplainCommandType = new IpcCommandType<undefined>('patch/explain'); | |||
export type UpdatePreferenceParams = UpdateablePreferences; | |||
export const UpdatePreferencesCommandType = new IpcCommandType<UpdatePreferenceParams>('patch/preferences/update'); | |||
export interface SwitchModeParams { | |||
repoPath?: string; | |||
mode: Mode; | |||
} | |||
export const SwitchModeCommandType = new IpcCommandType<SwitchModeParams>('patch/switchMode'); | |||
export const CopyCloudLinkCommandType = new IpcCommandType<undefined>('patch/cloud/copyLink'); | |||
export const CreateFromLocalPatchCommandType = new IpcCommandType<undefined>('patch/local/createPatch'); | |||
export interface UpdateCreatePatchRepositoryCheckedStateParams { | |||
repoUri: string; | |||
checked: boolean | 'staged'; | |||
} | |||
export const UpdateCreatePatchRepositoryCheckedStateCommandType = | |||
new IpcCommandType<UpdateCreatePatchRepositoryCheckedStateParams>('patch/create/repository/check'); | |||
export interface UpdateCreatePatchMetadataParams { | |||
title: string; | |||
description: string | undefined; | |||
} | |||
export const UpdateCreatePatchMetadataCommandType = new IpcCommandType<UpdateCreatePatchMetadataParams>( | |||
'patch/update/create/metadata', | |||
); | |||
// NOTIFICATIONS | |||
export interface DidChangeParams { | |||
state: Serialized<State>; | |||
} | |||
export const DidChangeNotificationType = new IpcNotificationType<DidChangeParams>('patch/didChange', true); | |||
export type DidChangeCreateParams = Pick<Serialized<State>, 'create' | 'mode'>; | |||
export const DidChangeCreateNotificationType = new IpcNotificationType<DidChangeCreateParams>('patch/create/didChange'); | |||
export type DidChangeDraftParams = Pick<Serialized<State>, 'draft' | 'mode'>; | |||
export const DidChangeDraftNotificationType = new IpcNotificationType<DidChangeDraftParams>('patch/draft/didChange'); | |||
export type DidChangePreferencesParams = Pick<Serialized<State>, 'preferences'>; | |||
export const DidChangePreferencesNotificationType = new IpcNotificationType<DidChangePreferencesParams>( | |||
'patch/preferences/didChange', | |||
); | |||
export type DidExplainParams = | |||
| { | |||
summary: string | undefined; | |||
error?: undefined; | |||
} | |||
| { error: { message: string } }; | |||
export const DidExplainCommandType = new IpcNotificationType<DidExplainParams>('patch/didExplain'); |
@ -0,0 +1,63 @@ | |||
import type { DraftSelectedEvent } from '../../../eventBus'; | |||
import type { Repository } from '../../../git/models/repository'; | |||
import { setContext } from '../../../system/context'; | |||
import type { Serialized } from '../../../system/serialize'; | |||
import type { WebviewsController } from '../../../webviews/webviewsController'; | |||
import type { Change, State } from './protocol'; | |||
interface CreateDraftFromChanges { | |||
title?: string; | |||
description?: string; | |||
changes: Change[]; | |||
repositories?: never; | |||
} | |||
interface CreateDraftFromRepositories { | |||
title?: string; | |||
description?: string; | |||
changes?: never; | |||
repositories: Repository[] | undefined; | |||
} | |||
export type CreateDraft = CreateDraftFromChanges | CreateDraftFromRepositories; | |||
export type ViewDraft = DraftSelectedEvent['data']['draft']; | |||
export type ShowCreateDraft = { | |||
mode: 'create'; | |||
create?: CreateDraft; | |||
}; | |||
export type ShowViewDraft = { | |||
mode: 'view'; | |||
draft: ViewDraft; | |||
}; | |||
export type PatchDetailsWebviewShowingArgs = [ShowCreateDraft | ShowViewDraft]; | |||
export function registerPatchDetailsWebviewView(controller: WebviewsController) { | |||
return controller.registerWebviewView<State, Serialized<State>, PatchDetailsWebviewShowingArgs>( | |||
{ | |||
id: 'gitlens.views.patchDetails', | |||
fileName: 'patchDetails.html', | |||
title: 'Patch', | |||
contextKeyPrefix: `gitlens:webviewView:patchDetails`, | |||
trackingFeature: 'patchDetailsView', | |||
plusFeature: true, | |||
webviewHostOptions: { | |||
retainContextWhenHidden: false, | |||
}, | |||
}, | |||
async (container, host) => { | |||
const { PatchDetailsWebviewProvider } = await import( | |||
/* webpackChunkName: "patchDetails" */ './patchDetailsWebview' | |||
); | |||
return new PatchDetailsWebviewProvider(container, host); | |||
}, | |||
async (...args) => { | |||
const arg = args[0]; | |||
if (arg == null) return; | |||
await setContext('gitlens:views:patchDetails:mode', 'state' in arg ? arg.state.mode : arg.mode); | |||
}, | |||
); | |||
} |
@ -0,0 +1,233 @@ | |||
import { Disposable } from 'vscode'; | |||
import type { Container } from '../../../container'; | |||
import type { GitFileChangeShape } from '../../../git/models/file'; | |||
import type { PatchRevisionRange } from '../../../git/models/patch'; | |||
import type { Repository } from '../../../git/models/repository'; | |||
import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; | |||
import type { Change, ChangeType, RevisionChange } from './protocol'; | |||
export interface RepositoryChangeset extends Disposable { | |||
type: ChangeType; | |||
repository: Repository; | |||
revision: PatchRevisionRange; | |||
getChange(): Promise<Change>; | |||
suspend(): void; | |||
resume(): void; | |||
checked: Change['checked']; | |||
expanded: boolean; | |||
} | |||
export class RepositoryRefChangeset implements RepositoryChangeset { | |||
readonly type = 'revision'; | |||
constructor( | |||
private readonly container: Container, | |||
public readonly repository: Repository, | |||
public readonly revision: PatchRevisionRange, | |||
private readonly files: RevisionChange['files'], | |||
checked: Change['checked'], | |||
expanded: boolean, | |||
) { | |||
this.checked = checked; | |||
this.expanded = expanded; | |||
} | |||
dispose() {} | |||
suspend() {} | |||
resume() {} | |||
private _checked: Change['checked'] = false; | |||
get checked(): Change['checked'] { | |||
return this._checked; | |||
} | |||
set checked(value: Change['checked']) { | |||
this._checked = value; | |||
} | |||
private _expanded = false; | |||
get expanded(): boolean { | |||
return this._expanded; | |||
} | |||
set expanded(value: boolean) { | |||
if (this._expanded === value) return; | |||
this._expanded = value; | |||
} | |||
// private _files: Promise<{ files: Change['files'] }> | undefined; | |||
// eslint-disable-next-line @typescript-eslint/require-await | |||
async getChange(): Promise<Change> { | |||
// let filesResult; | |||
// if (this.expanded) { | |||
// if (this._files == null) { | |||
// this._files = this.getFiles(); | |||
// } | |||
// filesResult = await this._files; | |||
// } | |||
return { | |||
type: 'revision', | |||
repository: { | |||
name: this.repository.name, | |||
path: this.repository.path, | |||
uri: this.repository.uri.toString(), | |||
}, | |||
revision: this.revision, | |||
files: this.files, //filesResult?.files, | |||
checked: this.checked, | |||
expanded: this.expanded, | |||
}; | |||
} | |||
// private async getFiles(): Promise<{ files: Change['files'] }> { | |||
// const commit = await this.container.git.getCommit(this.repository.path, this.range.sha!); | |||
// const files: GitFileChangeShape[] = []; | |||
// if (commit != null) { | |||
// for (const file of commit.files ?? []) { | |||
// const change = { | |||
// repoPath: file.repoPath, | |||
// path: file.path, | |||
// status: file.status, | |||
// originalPath: file.originalPath, | |||
// }; | |||
// files.push(change); | |||
// } | |||
// } | |||
// return { files: files }; | |||
// } | |||
} | |||
export class RepositoryWipChangeset implements RepositoryChangeset { | |||
readonly type = 'wip'; | |||
private _disposable: Disposable | undefined; | |||
constructor( | |||
private readonly container: Container, | |||
public readonly repository: Repository, | |||
public readonly revision: PatchRevisionRange, | |||
private readonly onDidChangeRepositoryWip: (e: RepositoryWipChangeset) => void, | |||
checked: Change['checked'], | |||
expanded: boolean, | |||
) { | |||
this.checked = checked; | |||
this.expanded = expanded; | |||
} | |||
dispose() { | |||
this._disposable?.dispose(); | |||
this._disposable = undefined; | |||
} | |||
suspend() { | |||
this._disposable?.dispose(); | |||
this._disposable = undefined; | |||
} | |||
resume() { | |||
if (this._expanded) { | |||
this.subscribe(); | |||
} | |||
} | |||
private _checked: Change['checked'] = false; | |||
get checked(): Change['checked'] { | |||
return this._checked; | |||
} | |||
set checked(value: Change['checked']) { | |||
this._checked = value; | |||
} | |||
private _expanded = false; | |||
get expanded(): boolean { | |||
return this._expanded; | |||
} | |||
set expanded(value: boolean) { | |||
if (this._expanded === value) return; | |||
this._files = undefined; | |||
if (value) { | |||
this.subscribe(); | |||
} else { | |||
this._disposable?.dispose(); | |||
this._disposable = undefined; | |||
} | |||
this._expanded = value; | |||
} | |||
private _files: Promise<{ files: Change['files'] }> | undefined; | |||
async getChange(): Promise<Change> { | |||
let filesResult; | |||
if (this.expanded) { | |||
if (this._files == null) { | |||
this._files = this.getFiles(); | |||
} | |||
filesResult = await this._files; | |||
} | |||
return { | |||
type: 'wip', | |||
repository: { | |||
name: this.repository.name, | |||
path: this.repository.path, | |||
uri: this.repository.uri.toString(), | |||
}, | |||
revision: this.revision, | |||
files: filesResult?.files, | |||
checked: this.checked, | |||
expanded: this.expanded, | |||
}; | |||
} | |||
private subscribe() { | |||
if (this._disposable != null) return; | |||
this._disposable = Disposable.from( | |||
this.repository.watchFileSystem(1000), | |||
this.repository.onDidChangeFileSystem(() => this.onDidChangeWip(), this), | |||
this.repository.onDidChange(e => { | |||
if (e.changed(RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) { | |||
this.onDidChangeWip(); | |||
} | |||
}), | |||
); | |||
} | |||
private onDidChangeWip() { | |||
this._files = undefined; | |||
this.onDidChangeRepositoryWip(this); | |||
} | |||
private async getFiles(): Promise<{ files: Change['files'] }> { | |||
const status = await this.container.git.getStatusForRepo(this.repository.path); | |||
const files: GitFileChangeShape[] = []; | |||
if (status != null) { | |||
for (const file of status.files) { | |||
const change = { | |||
repoPath: file.repoPath, | |||
path: file.path, | |||
status: file.status, | |||
originalPath: file.originalPath, | |||
staged: file.staged, | |||
}; | |||
files.push(change); | |||
if (file.staged && file.wip) { | |||
files.push({ ...change, staged: false }); | |||
} | |||
} | |||
} | |||
return { files: files }; | |||
} | |||
} |
@ -0,0 +1,9 @@ | |||
// eslint-disable-next-line @typescript-eslint/naming-convention | |||
declare const __brand: unique symbol; | |||
// eslint-disable-next-line @typescript-eslint/naming-convention | |||
declare const __base: unique symbol; | |||
type _Brand<Base, B> = { [__brand]: B; [__base]: Base }; | |||
export type Branded<Base, B> = Base & _Brand<Base, B>; | |||
export type Brand<B extends Branded<any, any>> = B extends Branded<any, any> ? B : never; | |||
export type Unbrand<T> = T extends _Brand<infer Base, any> ? Base : never; |
@ -0,0 +1,169 @@ | |||
import type { CancellationToken, Disposable } from 'vscode'; | |||
import { TreeItem, TreeItemCollapsibleState, window } from 'vscode'; | |||
import type { RepositoriesViewConfig } from '../config'; | |||
import { Commands } from '../constants'; | |||
import type { Container } from '../container'; | |||
import { unknownGitUri } from '../git/gitUri'; | |||
import type { Draft } from '../gk/models/drafts'; | |||
import { showPatchesView } from '../plus/drafts/actions'; | |||
import { ensurePlusFeaturesEnabled } from '../plus/gk/utils'; | |||
import { executeCommand } from '../system/command'; | |||
import { gate } from '../system/decorators/gate'; | |||
import { CacheableChildrenViewNode } from './nodes/abstract/cacheableChildrenViewNode'; | |||
import type { ViewNode } from './nodes/abstract/viewNode'; | |||
import { DraftNode } from './nodes/draftNode'; | |||
import { ViewBase } from './viewBase'; | |||
import { registerViewCommand } from './viewCommands'; | |||
export class DraftsViewNode extends CacheableChildrenViewNode<'drafts', DraftsView, DraftNode> { | |||
constructor(view: DraftsView) { | |||
super('drafts', unknownGitUri, view); | |||
} | |||
async getChildren(): Promise<ViewNode[]> { | |||
if (this.children == null) { | |||
const children: DraftNode[] = []; | |||
const drafts = await this.view.container.drafts.getDrafts(); | |||
drafts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); | |||
for (const draft of drafts) { | |||
children.push(new DraftNode(this.uri, this.view, this, draft)); | |||
} | |||
this.children = children; | |||
} | |||
return this.children; | |||
} | |||
getTreeItem(): TreeItem { | |||
const item = new TreeItem('Drafts', TreeItemCollapsibleState.Expanded); | |||
return item; | |||
} | |||
} | |||
export class DraftsView extends ViewBase<'drafts', DraftsViewNode, RepositoriesViewConfig> { | |||
protected readonly configKey = 'drafts'; | |||
private _disposable: Disposable | undefined; | |||
constructor(container: Container) { | |||
super(container, 'drafts', 'Cloud Patches', 'draftsView'); | |||
this.description = `PREVIEW\u00a0\u00a0☁️`; | |||
} | |||
override dispose() { | |||
this._disposable?.dispose(); | |||
super.dispose(); | |||
} | |||
override get canSelectMany(): boolean { | |||
return false; | |||
} | |||
protected getRoot() { | |||
return new DraftsViewNode(this); | |||
} | |||
override async show(options?: { preserveFocus?: boolean | undefined }): Promise<void> { | |||
if (!(await ensurePlusFeaturesEnabled())) return; | |||
// if (this._disposable == null) { | |||
// this._disposable = Disposable.from( | |||
// this.container.drafts.onDidResetDrafts(() => void this.ensureRoot().triggerChange(true)), | |||
// ); | |||
// } | |||
return super.show(options); | |||
} | |||
override get canReveal(): boolean { | |||
return false; | |||
} | |||
protected registerCommands(): Disposable[] { | |||
void this.container.viewCommands; | |||
return [ | |||
// registerViewCommand( | |||
// this.getQualifiedCommand('info'), | |||
// () => env.openExternal(Uri.parse('https://help.gitkraken.com/gitlens/side-bar/#drafts-☁%ef%b8%8f')), | |||
// this, | |||
// ), | |||
registerViewCommand( | |||
this.getQualifiedCommand('copy'), | |||
() => executeCommand(Commands.ViewsCopy, this.activeSelection, this.selection), | |||
this, | |||
), | |||
registerViewCommand(this.getQualifiedCommand('refresh'), () => this.refresh(true), this), | |||
registerViewCommand( | |||
this.getQualifiedCommand('create'), | |||
async () => { | |||
await executeCommand(Commands.CreateCloudPatch); | |||
void this.ensureRoot().triggerChange(true); | |||
}, | |||
this, | |||
), | |||
registerViewCommand( | |||
this.getQualifiedCommand('delete'), | |||
async (node: DraftNode) => { | |||
const confirm = { title: 'Delete' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showInformationMessage( | |||
`Are you sure you want to delete draft '${node.draft.title}'?`, | |||
{ modal: true }, | |||
confirm, | |||
cancel, | |||
); | |||
if (result === confirm) { | |||
await this.container.drafts.deleteDraft(node.draft.id); | |||
void node.getParent()?.triggerChange(true); | |||
} | |||
}, | |||
this, | |||
), | |||
registerViewCommand( | |||
this.getQualifiedCommand('open'), | |||
async (node: DraftNode) => { | |||
let draft = node.draft; | |||
if (draft.changesets == null) { | |||
draft = await this.container.drafts.getDraft(node.draft.id); | |||
} | |||
void showPatchesView({ mode: 'view', draft: draft }); | |||
}, | |||
this, | |||
), | |||
]; | |||
} | |||
async findDraft(draft: Draft, cancellation?: CancellationToken) { | |||
return this.findNode((n: any) => n.draft?.id === draft.id, { | |||
allowPaging: false, | |||
maxDepth: 2, | |||
canTraverse: n => { | |||
if (n instanceof DraftsViewNode) return true; | |||
return false; | |||
}, | |||
token: cancellation, | |||
}); | |||
} | |||
@gate(() => '') | |||
async revealDraft( | |||
draft: Draft, | |||
options?: { | |||
select?: boolean; | |||
focus?: boolean; | |||
expand?: boolean | number; | |||
}, | |||
) { | |||
const node = await this.findDraft(draft); | |||
if (node == null) return undefined; | |||
await this.ensureRevealNode(node, options); | |||
return node; | |||
} | |||
} |
@ -0,0 +1,60 @@ | |||
import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; | |||
import type { GitUri } from '../../git/gitUri'; | |||
import type { Draft } from '../../gk/models/drafts'; | |||
import { configuration } from '../../system/configuration'; | |||
import { formatDate, fromNow } from '../../system/date'; | |||
import type { DraftsView } from '../draftsView'; | |||
import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; | |||
export class DraftNode extends ViewNode<'draft', DraftsView> { | |||
constructor( | |||
uri: GitUri, | |||
view: DraftsView, | |||
protected override parent: ViewNode, | |||
public readonly draft: Draft, | |||
) { | |||
super('draft', uri, view, parent); | |||
this.updateContext({ draft: draft }); | |||
this._uniqueId = getViewNodeId(this.type, this.context); | |||
} | |||
override get id(): string { | |||
return this._uniqueId; | |||
} | |||
override toClipboard(): string { | |||
return this.draft.title ?? this.draft.description ?? ''; | |||
} | |||
getChildren(): ViewNode[] { | |||
return []; | |||
} | |||
getTreeItem(): TreeItem { | |||
const label = this.draft.title ?? `Draft (${this.draft.id})`; | |||
const item = new TreeItem(label, TreeItemCollapsibleState.None); | |||
const dateFormat = configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma'; | |||
const showUpdated = this.draft.updatedAt.getTime() - this.draft.createdAt.getTime() >= 1000; | |||
item.id = this.id; | |||
item.contextValue = ContextValues.Draft; | |||
item.iconPath = new ThemeIcon('cloud'); | |||
item.tooltip = new MarkdownString( | |||
`${label}${this.draft.description ? `\\\n${this.draft.description}` : ''}\n\nCreated ${fromNow( | |||
this.draft.createdAt, | |||
)} _(${formatDate(this.draft.createdAt, dateFormat)})_${ | |||
showUpdated | |||
? ` \\\nLast updated ${fromNow(this.draft.updatedAt)} _(${formatDate( | |||
this.draft.updatedAt, | |||
dateFormat, | |||
)})_` | |||
: '' | |||
}`, | |||
); | |||
item.description = fromNow(this.draft.updatedAt); | |||
return item; | |||
} | |||
} |
@ -1,469 +1,31 @@ | |||
@use '../shared/styles/theme'; | |||
@use '../shared/styles/details-base'; | |||
:root { | |||
--gitlens-gutter-width: 20px; | |||
--gitlens-scrollbar-gutter-width: 10px; | |||
} | |||
.vscode-high-contrast, | |||
.vscode-dark { | |||
--color-background--level-05: var(--color-background--lighten-05); | |||
--color-background--level-075: var(--color-background--lighten-075); | |||
--color-background--level-10: var(--color-background--lighten-10); | |||
--color-background--level-15: var(--color-background--lighten-15); | |||
--color-background--level-30: var(--color-background--lighten-30); | |||
} | |||
.vscode-high-contrast-light, | |||
.vscode-light { | |||
--color-background--level-05: var(--color-background--darken-05); | |||
--color-background--level-075: var(--color-background--darken-075); | |||
--color-background--level-10: var(--color-background--darken-10); | |||
--color-background--level-15: var(--color-background--darken-15); | |||
--color-background--level-30: var(--color-background--darken-30); | |||
} | |||
// generic resets | |||
html { | |||
font-size: 62.5%; | |||
// box-sizing: border-box; | |||
font-family: var(--font-family); | |||
} | |||
*, | |||
*:before, | |||
*:after { | |||
// TODO: "change-list__action" should be a separate component | |||
.change-list__action { | |||
box-sizing: border-box; | |||
} | |||
body { | |||
--gk-badge-outline-color: var(--vscode-badge-foreground); | |||
--gk-badge-filled-background-color: var(--vscode-badge-background); | |||
--gk-badge-filled-color: var(--vscode-badge-foreground); | |||
font-family: var(--font-family); | |||
font-size: var(--font-size); | |||
color: var(--color-foreground); | |||
padding: 0; | |||
&.scrollable, | |||
.scrollable { | |||
border-color: transparent; | |||
transition: border-color 1s linear; | |||
&:hover, | |||
&:focus-within { | |||
&.scrollable, | |||
.scrollable { | |||
border-color: var(--vscode-scrollbarSlider-background); | |||
transition: none; | |||
} | |||
} | |||
} | |||
&.preload { | |||
&.scrollable, | |||
.scrollable { | |||
transition: none; | |||
} | |||
} | |||
} | |||
::-webkit-scrollbar-corner { | |||
background-color: transparent !important; | |||
} | |||
::-webkit-scrollbar-thumb { | |||
background-color: transparent; | |||
border-color: inherit; | |||
border-right-style: inset; | |||
border-right-width: calc(100vw + 100vh); | |||
border-radius: unset !important; | |||
&:hover { | |||
border-color: var(--vscode-scrollbarSlider-hoverBackground); | |||
} | |||
&:active { | |||
border-color: var(--vscode-scrollbarSlider-activeBackground); | |||
} | |||
} | |||
a { | |||
text-decoration: none; | |||
&:hover { | |||
text-decoration: underline; | |||
} | |||
} | |||
ul { | |||
list-style: none; | |||
margin: 0; | |||
padding: 0; | |||
} | |||
.bulleted { | |||
list-style: disc; | |||
padding-left: 1.2em; | |||
> li + li { | |||
margin-top: 0.25em; | |||
} | |||
} | |||
.button { | |||
--button-foreground: var(--vscode-button-foreground); | |||
--button-background: var(--vscode-button-background); | |||
--button-hover-background: var(--vscode-button-hoverBackground); | |||
display: inline-block; | |||
border: none; | |||
padding: 0.4rem; | |||
font-family: inherit; | |||
font-size: inherit; | |||
line-height: 1.4; | |||
text-align: center; | |||
text-decoration: none; | |||
user-select: none; | |||
background: var(--button-background); | |||
color: var(--button-foreground); | |||
cursor: pointer; | |||
&:hover { | |||
background: var(--button-hover-background); | |||
} | |||
&:focus { | |||
outline: 1px solid var(--vscode-focusBorder); | |||
outline-offset: 0.2rem; | |||
} | |||
&--full { | |||
width: 100%; | |||
} | |||
code-icon { | |||
pointer-events: none; | |||
} | |||
} | |||
.button--busy { | |||
code-icon { | |||
margin-right: 0.5rem; | |||
} | |||
&[aria-busy='true'] { | |||
opacity: 0.5; | |||
} | |||
&:not([aria-busy='true']) { | |||
code-icon { | |||
display: none; | |||
} | |||
} | |||
} | |||
.button-container { | |||
margin: 1rem auto 0; | |||
text-align: left; | |||
max-width: 30rem; | |||
transition: max-width 0.2s ease-out; | |||
} | |||
@media (min-width: 640px) { | |||
.button-container { | |||
max-width: 100%; | |||
} | |||
} | |||
.button-group { | |||
display: inline-flex; | |||
gap: 0.1rem; | |||
width: 100%; | |||
max-width: 30rem; | |||
} | |||
.section { | |||
padding: 0 var(--gitlens-scrollbar-gutter-width) 1.5rem var(--gitlens-gutter-width); | |||
> :first-child { | |||
margin-top: 0; | |||
} | |||
> :last-child { | |||
margin-bottom: 0; | |||
} | |||
} | |||
.section--message { | |||
padding: { | |||
top: 1rem; | |||
bottom: 1.75rem; | |||
} | |||
} | |||
.section--empty { | |||
> :last-child { | |||
margin-top: 0.5rem; | |||
} | |||
} | |||
.section--skeleton { | |||
padding: { | |||
top: 1px; | |||
bottom: 1px; | |||
} | |||
} | |||
.commit-action { | |||
display: inline-flex; | |||
justify-content: center; | |||
align-items: center; | |||
height: 21px; | |||
width: 2rem; | |||
height: 2rem; | |||
border-radius: 0.25em; | |||
color: inherit; | |||
padding: 0.2rem; | |||
padding: 2px; | |||
vertical-align: text-bottom; | |||
text-decoration: none; | |||
> * { | |||
pointer-events: none; | |||
} | |||
&:focus { | |||
outline: 1px solid var(--vscode-focusBorder); | |||
outline-offset: -1px; | |||
} | |||
&:hover { | |||
color: var(--vscode-foreground); | |||
text-decoration: none; | |||
.vscode-dark & { | |||
background-color: var(--color-background--lighten-15); | |||
} | |||
.vscode-light & { | |||
background-color: var(--color-background--darken-15); | |||
} | |||
} | |||
&.is-active { | |||
.vscode-dark & { | |||
background-color: var(--color-background--lighten-10); | |||
} | |||
.vscode-light & { | |||
background-color: var(--color-background--darken-10); | |||
} | |||
} | |||
&.is-disabled { | |||
opacity: 0.5; | |||
pointer-events: none; | |||
} | |||
&.is-hidden { | |||
display: none; | |||
} | |||
&--emphasis-low:not(:hover, :focus, :active) { | |||
opacity: 0.5; | |||
} | |||
} | |||
.change-list { | |||
margin-bottom: 1rem; | |||
} | |||
.message-block { | |||
font-size: 1.3rem; | |||
border: 1px solid var(--vscode-input-border); | |||
background: var(--vscode-input-background); | |||
padding: 0.5rem; | |||
&__text { | |||
margin: 0; | |||
overflow-y: auto; | |||
overflow-x: hidden; | |||
max-height: 9rem; | |||
> * { | |||
white-space: break-spaces; | |||
} | |||
strong { | |||
font-weight: 600; | |||
font-size: 1.4rem; | |||
} | |||
} | |||
} | |||
.top-details { | |||
position: sticky; | |||
top: 0; | |||
z-index: 1; | |||
padding: { | |||
top: 0.1rem; | |||
left: var(--gitlens-gutter-width); | |||
right: var(--gitlens-scrollbar-gutter-width); | |||
bottom: 0.5rem; | |||
} | |||
background-color: var(--vscode-sideBar-background); | |||
&__actionbar { | |||
display: flex; | |||
flex-direction: row; | |||
flex-wrap: wrap; | |||
align-items: center; | |||
justify-content: space-between; | |||
&-group { | |||
display: flex; | |||
flex: none; | |||
} | |||
&--highlight { | |||
margin-left: 0.25em; | |||
padding: 0 4px 2px 4px; | |||
border: 1px solid var(--color-background--level-15); | |||
border-radius: 0.3rem; | |||
font-family: var(--vscode-editor-font-family); | |||
} | |||
&.is-pinned { | |||
background-color: var(--color-alert-warningBackground); | |||
box-shadow: 0 0 0 0.1rem var(--color-alert-warningBorder); | |||
border-radius: 0.3rem; | |||
.commit-action:hover, | |||
.commit-action.is-active { | |||
background-color: var(--color-alert-warningHoverBackground); | |||
} | |||
} | |||
} | |||
&__sha { | |||
margin: 0 0.5rem 0 0.25rem; | |||
} | |||
&__authors { | |||
flex-basis: 100%; | |||
padding-top: 0.5rem; | |||
} | |||
&__author { | |||
& + & { | |||
margin-top: 0.5rem; | |||
} | |||
} | |||
} | |||
.issue > :not(:first-child) { | |||
margin-top: 0.5rem; | |||
} | |||
.commit-detail-panel { | |||
max-height: 100vh; | |||
overflow: auto; | |||
scrollbar-gutter: stable; | |||
color: var(--vscode-sideBar-foreground); | |||
background-color: var(--vscode-sideBar-background); | |||
[aria-hidden='true'] { | |||
display: none; | |||
} | |||
.change-list__action:focus { | |||
outline: 1px solid var(--vscode-focusBorder); | |||
outline-offset: -1px; | |||
} | |||
.ai-content { | |||
font-size: 1.3rem; | |||
border: 0.1rem solid var(--vscode-input-border, transparent); | |||
background: var(--vscode-input-background); | |||
margin-top: 1rem; | |||
padding: 0.5rem; | |||
&.has-error { | |||
border-left-color: var(--color-alert-errorBorder); | |||
border-left-width: 0.3rem; | |||
padding-left: 0.8rem; | |||
} | |||
&:empty { | |||
display: none; | |||
} | |||
&__summary { | |||
margin: 0; | |||
overflow-y: auto; | |||
overflow-x: hidden; | |||
// max-height: 9rem; | |||
white-space: break-spaces; | |||
.has-error & { | |||
white-space: normal; | |||
} | |||
} | |||
.change-list__action:hover { | |||
color: inherit; | |||
background-color: var(--vscode-toolbar-hoverBackground); | |||
text-decoration: none; | |||
} | |||
.wip-details { | |||
display: flex; | |||
padding: 0.4rem 0.8rem; | |||
background: var(--color-alert-infoBackground); | |||
border-left: 0.3rem solid var(--color-alert-infoBorder); | |||
align-items: center; | |||
justify-content: space-between; | |||
.wip-changes { | |||
display: inline-flex; | |||
align-items: baseline; | |||
} | |||
.wip-branch { | |||
display: inline-block; | |||
padding: 0 0.3rem 0.2rem; | |||
margin-left: 0.4rem; | |||
// background: var(--color-background--level-05); | |||
border: 1px solid var(--color-foreground--50); | |||
border-radius: 0.3rem; | |||
} | |||
gl-button { | |||
padding: 0.2rem 0.8rem; | |||
opacity: 0.8; | |||
} | |||
.change-list__action:active { | |||
background-color: var(--vscode-toolbar-activeBackground); | |||
} | |||
.details-tab { | |||
display: flex; | |||
justify-content: stretch; | |||
align-items: center; | |||
margin-bottom: 0.4rem; | |||
gap: 0.2rem; | |||
& > * { | |||
flex: 1; | |||
} | |||
&__item { | |||
appearance: none; | |||
padding: 0.4rem; | |||
color: var(--color-foreground--85); | |||
background-color: transparent; | |||
border: none; | |||
border-bottom: 0.2rem solid transparent; | |||
cursor: pointer; | |||
// background-color: #00000030; | |||
line-height: 1.8rem; | |||
gk-badge { | |||
line-height: 1.36rem; | |||
} | |||
&:hover { | |||
color: var(--color-foreground); | |||
// background-color: var(--vscode-button-hoverBackground); | |||
background-color: #00000020; | |||
} | |||
&.is-active { | |||
color: var(--color-foreground); | |||
border-bottom-color: var(--vscode-button-hoverBackground); | |||
} | |||
} | |||
.change-list__action.is-disabled { | |||
opacity: 0.5; | |||
} |
@ -0,0 +1,692 @@ | |||
import { defineGkElement, Menu, MenuItem, Popover } from '@gitkraken/shared-web-components'; | |||
import { html } from 'lit'; | |||
import { customElement, property, state } from 'lit/decorators.js'; | |||
import { ifDefined } from 'lit/directives/if-defined.js'; | |||
import { unsafeHTML } from 'lit/directives/unsafe-html.js'; | |||
import { when } from 'lit/directives/when.js'; | |||
import type { TextDocumentShowOptions } from 'vscode'; | |||
import type { DraftPatchFileChange } from '../../../../../gk/models/drafts'; | |||
import type { DraftDetails, FileActionParams, State } from '../../../../../plus/webviews/patchDetails/protocol'; | |||
import { makeHierarchical } from '../../../../../system/array'; | |||
import { flatCount } from '../../../../../system/iterable'; | |||
import type { | |||
TreeItemActionDetail, | |||
TreeItemBase, | |||
TreeItemCheckedDetail, | |||
TreeItemSelectionDetail, | |||
TreeModel, | |||
} from '../../../shared/components/tree/base'; | |||
import { GlTreeBase } from './gl-tree-base'; | |||
import '../../../shared/components/actions/action-item'; | |||
import '../../../shared/components/actions/action-nav'; | |||
import '../../../shared/components/button-container'; | |||
import '../../../shared/components/button'; | |||
import '../../../shared/components/code-icon'; | |||
import '../../../shared/components/commit/commit-identity'; | |||
import '../../../shared/components/tree/tree-generator'; | |||
import '../../../shared/components/webview-pane'; | |||
// Can only import types from 'vscode' | |||
const BesideViewColumn = -2; /*ViewColumn.Beside*/ | |||
interface ExplainState { | |||
cancelled?: boolean; | |||
error?: { message: string }; | |||
summary?: string; | |||
} | |||
export interface ApplyPatchDetail { | |||
draft: DraftDetails; | |||
target?: 'current' | 'branch' | 'worktree'; | |||
base?: string; | |||
selectedPatches?: string[]; | |||
// [key: string]: unknown; | |||
} | |||
export interface ChangePatchBaseDetail { | |||
draft: DraftDetails; | |||
// [key: string]: unknown; | |||
} | |||
export interface SelectPatchRepoDetail { | |||
draft: DraftDetails; | |||
repoPath?: string; | |||
// [key: string]: unknown; | |||
} | |||
export interface ShowPatchInGraphDetail { | |||
draft: DraftDetails; | |||
// [key: string]: unknown; | |||
} | |||
@customElement('gl-draft-details') | |||
export class GlDraftDetails extends GlTreeBase { | |||
@property({ type: Object }) | |||
state!: State; | |||
@state() | |||
explainBusy = false; | |||
@property({ type: Object }) | |||
explain?: ExplainState; | |||
@state() | |||
selectedPatches: string[] = []; | |||
@state() | |||
validityMessage?: string; | |||
get canSubmit() { | |||
return this.selectedPatches.length > 0; | |||
// return this.state.draft?.repoPath != null && this.state.draft?.baseRef != null; | |||
} | |||
constructor() { | |||
super(); | |||
defineGkElement(Popover, Menu, MenuItem); | |||
} | |||
override updated(changedProperties: Map<string, any>) { | |||
if (changedProperties.has('explain')) { | |||
this.explainBusy = false; | |||
this.querySelector('[data-region="ai-explanation"]')?.scrollIntoView(); | |||
} | |||
if (changedProperties.has('state')) { | |||
const patches = this.state?.draft?.patches; | |||
if (!patches?.length) { | |||
this.selectedPatches = []; | |||
} else { | |||
this.selectedPatches = patches.map(p => p.id); | |||
} | |||
// } else if (patches?.length === 1) { | |||
// this.selectedPatches = [patches[0].id]; | |||
// } else { | |||
// this.selectedPatches = this.selectedPatches.filter(id => { | |||
// return patches.find(p => p.id === id) != null; | |||
// }); | |||
// } | |||
} | |||
} | |||
private renderEmptyContent() { | |||
return html` | |||
<div class="section section--empty" id="empty"> | |||
<button-container> | |||
<gl-button full href="command:gitlens.openPatch">Open Patch...</gl-button> | |||
</button-container> | |||
</div> | |||
`; | |||
} | |||
private renderPatchMessage() { | |||
if (this.state?.draft?.title == null) { | |||
return undefined; | |||
} | |||
const title = this.state.draft.title; | |||
const description = this.state.draft.draftType === 'cloud' ? this.state.draft.description : undefined; | |||
return html` | |||
<div class="section section--message"> | |||
<div class="message-block"> | |||
${when( | |||
description == null, | |||
() => | |||
html`<p class="message-block__text scrollable" data-region="message"> | |||
<strong>${unsafeHTML(title)}</strong> | |||
</p>`, | |||
() => | |||
html`<p class="message-block__text scrollable" data-region="message"> | |||
<strong>${unsafeHTML(title)}</strong><br /><span>${unsafeHTML(description)}</span> | |||
</p>`, | |||
)} | |||
</div> | |||
</div> | |||
`; | |||
} | |||
private renderExplainAi() { | |||
// TODO: add loading and response states | |||
return html` | |||
<webview-pane collapsable data-region="explain-pane"> | |||
<span slot="title">Explain (AI)</span> | |||
<span slot="subtitle"><code-icon icon="beaker" size="12"></code-icon></span> | |||
<action-nav slot="actions"> | |||
<action-item data-action="switch-ai" label="Switch AI Model" icon="hubot"></action-item> | |||
</action-nav> | |||
<div class="section"> | |||
<p>Let AI assist in understanding the changes made with this patch.</p> | |||
<p class="button-container"> | |||
<span class="button-group button-group--single"> | |||
<gl-button | |||
full | |||
class="button--busy" | |||
data-action="ai-explain" | |||
aria-busy="${ifDefined(this.explainBusy ? 'true' : undefined)}" | |||
@click=${this.onExplainChanges} | |||
@keydown=${this.onExplainChanges} | |||
><code-icon icon="loading" modifier="spin"></code-icon>Explain Changes</gl-button | |||
> | |||
</span> | |||
</p> | |||
${when( | |||
this.explain, | |||
() => html` | |||
<div | |||
class="ai-content${this.explain?.error ? ' has-error' : ''}" | |||
data-region="ai-explanation" | |||
> | |||
${when( | |||
this.explain?.error, | |||
() => | |||
html`<p class="ai-content__summary scrollable"> | |||
${this.explain!.error!.message ?? 'Error retrieving content'} | |||
</p>`, | |||
)} | |||
${when( | |||
this.explain?.summary, | |||
() => html`<p class="ai-content__summary scrollable">${this.explain!.summary}</p>`, | |||
)} | |||
</div> | |||
`, | |||
)} | |||
</div> | |||
</webview-pane> | |||
`; | |||
} | |||
// private renderCommitStats() { | |||
// if (this.state?.draft?.stats?.changedFiles == null) { | |||
// return undefined; | |||
// } | |||
// if (typeof this.state.draft.stats.changedFiles === 'number') { | |||
// return html`<commit-stats | |||
// .added=${undefined} | |||
// modified="${this.state.draft.stats.changedFiles}" | |||
// .removed=${undefined} | |||
// ></commit-stats>`; | |||
// } | |||
// const { added, deleted, changed } = this.state.draft.stats.changedFiles; | |||
// return html`<commit-stats added="${added}" modified="${changed}" removed="${deleted}"></commit-stats>`; | |||
// } | |||
private renderChangedFiles() { | |||
const layout = this.state?.preferences?.files?.layout ?? 'auto'; | |||
return html` | |||
<webview-pane collapsable expanded> | |||
<span slot="title">Files changed </span> | |||
<!-- <span slot="subtitle" data-region="stats">\${this.renderCommitStats()}</span> --> | |||
<action-nav slot="actions">${this.renderLayoutAction(layout)}</action-nav> | |||
${when( | |||
this.validityMessage != null, | |||
() => | |||
html`<div class="section"> | |||
<div class="alert alert--error"> | |||
<code-icon icon="error"></code-icon> | |||
<p class="alert__content">${this.validityMessage}</p> | |||
</div> | |||
</div>`, | |||
)} | |||
<div class="change-list" data-region="files"> | |||
${when( | |||
this.state?.draft?.patches == null, | |||
() => this.renderLoading(), | |||
() => this.renderTreeView(this.treeModel, this.state?.preferences?.indentGuides), | |||
)} | |||
</div> | |||
</webview-pane> | |||
`; | |||
} | |||
get treeModel(): TreeModel[] { | |||
if (this.state?.draft?.patches == null) return []; | |||
const { | |||
draft: { patches }, | |||
} = this.state; | |||
const layout = this.state?.preferences?.files?.layout ?? 'auto'; | |||
let isTree = false; | |||
const fileCount = flatCount(patches, p => p?.files?.length ?? 0); | |||
if (layout === 'auto') { | |||
isTree = fileCount > (this.state.preferences?.files?.threshold ?? 5); | |||
} else { | |||
isTree = layout === 'tree'; | |||
} | |||
// checkable only for multi-repo | |||
const options = { checkable: patches.length > 1 }; | |||
const models = patches?.map(p => | |||
this.draftPatchToTreeModel(p, isTree, this.state.preferences?.files?.compact, options), | |||
); | |||
return models; | |||
} | |||
renderPatches() { | |||
// // const path = this.state.draft?.repoPath; | |||
// const repo = this.state.draft?.repoName; | |||
// const base = this.state.draft?.baseRef; | |||
// const getActions = () => { | |||
// if (!repo) { | |||
// return html` | |||
// <a href="#" class="commit-action" data-action="select-patch-repo" @click=${this.onSelectPatchRepo} | |||
// ><code-icon icon="repo" title="Repository" aria-label="Repository"></code-icon | |||
// ><span class="top-details__sha">Select base repo</span></a | |||
// > | |||
// <a href="#" class="commit-action is-disabled"><code-icon icon="gl-graph"></code-icon></a> | |||
// `; | |||
// } | |||
// if (!base) { | |||
// return html` | |||
// <a href="#" class="commit-action" data-action="select-patch-repo" @click=${this.onSelectPatchRepo} | |||
// ><code-icon icon="repo" title="Repository" aria-label="Repository"></code-icon | |||
// ><span class="top-details__sha">${repo}</span></a | |||
// > | |||
// <a href="#" class="commit-action" data-action="select-patch-base" @click=${this.onChangePatchBase} | |||
// ><code-icon icon="git-commit" title="Repository" aria-label="Repository"></code-icon | |||
// ><span class="top-details__sha">Select base</span></a | |||
// > | |||
// <a href="#" class="commit-action is-disabled"><code-icon icon="gl-graph"></code-icon></a> | |||
// `; | |||
// } | |||
// return html` | |||
// <a href="#" class="commit-action" data-action="select-patch-repo" @click=${this.onSelectPatchRepo} | |||
// ><code-icon icon="repo" title="Repository" aria-label="Repository"></code-icon | |||
// ><span class="top-details__sha">${repo}</span></a | |||
// > | |||
// <a href="#" class="commit-action" data-action="select-patch-base" @click=${this.onChangePatchBase} | |||
// ><code-icon icon="git-commit"></code-icon | |||
// ><span class="top-details__sha">${base?.substring(0, 7)}</span></a | |||
// > | |||
// <a href="#" class="commit-action" data-action="patch-base-in-graph" @click=${this.onShowInGraph} | |||
// ><code-icon icon="gl-graph"></code-icon | |||
// ></a> | |||
// `; | |||
// }; | |||
// <div class="section"> | |||
// <div class="patch-base">${getActions()}</div> | |||
// </div> | |||
return html` | |||
<webview-pane expanded> | |||
<span slot="title">Apply</span> | |||
<div class="section section--sticky-actions"> | |||
<p class="button-container"> | |||
<span class="button-group button-group--single"> | |||
<gl-button full @click=${this.onApplyPatch}>Apply Cloud Patch</gl-button> | |||
</span> | |||
</p> | |||
</div> | |||
</webview-pane> | |||
`; | |||
} | |||
// renderCollaborators() { | |||
// return html` | |||
// <webview-pane collapsable expanded> | |||
// <span slot="title">Collaborators</span> | |||
// <div class="h-spacing"> | |||
// <list-container> | |||
// <list-item> | |||
// <code-icon | |||
// slot="icon" | |||
// icon="account" | |||
// title="Collaborator" | |||
// aria-label="Collaborator" | |||
// ></code-icon> | |||
// justin.roberts@gitkraken.com | |||
// </list-item> | |||
// <list-item> | |||
// <code-icon | |||
// slot="icon" | |||
// icon="account" | |||
// title="Collaborator" | |||
// aria-label="Collaborator" | |||
// ></code-icon> | |||
// eamodio@gitkraken.com | |||
// </list-item> | |||
// <list-item> | |||
// <code-icon | |||
// slot="icon" | |||
// icon="account" | |||
// title="Collaborator" | |||
// aria-label="Collaborator" | |||
// ></code-icon> | |||
// keith.daulton@gitkraken.com | |||
// </list-item> | |||
// </list-container> | |||
// </div> | |||
// </webview-pane> | |||
// `; | |||
// } | |||
override render() { | |||
if (this.state?.draft == null) { | |||
return html` <div class="commit-detail-panel scrollable">${this.renderEmptyContent()}</div>`; | |||
} | |||
return html` | |||
<div class="pane-groups"> | |||
<div class="pane-groups__group-fixed"> | |||
<div class="top-details"> | |||
<div class="top-details__top-menu"> | |||
<div class="top-details__actionbar"> | |||
<div class="top-details__actionbar-group"></div> | |||
<div class="top-details__actionbar-group"> | |||
${when( | |||
this.state?.draft?.draftType === 'cloud', | |||
() => html` | |||
<a class="commit-action" href="#" @click=${this.onCopyCloudLink}> | |||
<code-icon icon="link"></code-icon> | |||
<span class="top-details__sha">Copy Link</span></a | |||
> | |||
`, | |||
() => html` | |||
<a | |||
class="commit-action" | |||
href="#" | |||
aria-label="Share Patch" | |||
title="Share Patch" | |||
@click=${this.onShareLocalPatch} | |||
>Share</a | |||
> | |||
`, | |||
)} | |||
<a | |||
class="commit-action" | |||
href="#" | |||
aria-label="Show Patch Actions" | |||
title="Show Patch Actions" | |||
><code-icon icon="kebab-vertical"></code-icon | |||
></a> | |||
</div> | |||
</div> | |||
${when( | |||
this.state.draft?.draftType === 'cloud' && this.state.draft?.author.name != null, | |||
() => html` | |||
<ul class="top-details__authors" aria-label="Authors"> | |||
<li class="top-details__author" data-region="author"> | |||
<commit-identity | |||
name="${this.state.draft!.author!.name}" | |||
email="${ifDefined(this.state.draft!.author!.email)}" | |||
date="${this.state.draft!.createdAt!}" | |||
dateFormat="${this.state.preferences.dateFormat}" | |||
avatarUrl="${this.state.draft!.author!.avatar ?? ''}" | |||
?showavatar=${this.state.preferences?.avatars ?? true} | |||
.actionLabel=${'created'} | |||
></commit-identity> | |||
</li> | |||
</ul> | |||
`, | |||
)} | |||
</div> | |||
</div> | |||
${this.renderPatchMessage()} | |||
</div> | |||
<div class="pane-groups__group">${this.renderChangedFiles()}</div> | |||
<div class="pane-groups__group-fixed pane-groups__group--bottom"> | |||
${this.renderExplainAi()}${this.renderPatches()} | |||
</div> | |||
</div> | |||
`; | |||
} | |||
protected override createRenderRoot() { | |||
return this; | |||
} | |||
onExplainChanges(e: MouseEvent | KeyboardEvent) { | |||
if (this.explainBusy === true || (e instanceof KeyboardEvent && e.key !== 'Enter')) { | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
return; | |||
} | |||
this.explainBusy = true; | |||
} | |||
override onTreeItemActionClicked(e: CustomEvent<TreeItemActionDetail>) { | |||
if (!e.detail.context || !e.detail.action) return; | |||
const action = e.detail.action; | |||
switch (action.action) { | |||
// repo actions | |||
case 'apply-patch': | |||
this.onApplyPatch(); | |||
break; | |||
case 'change-patch-base': | |||
this.onChangePatchBase(); | |||
break; | |||
case 'show-patch-in-graph': | |||
this.onShowInGraph(); | |||
break; | |||
// file actions | |||
case 'file-open': | |||
this.onOpenFile(e); | |||
break; | |||
case 'file-compare-working': | |||
this.onCompareWorking(e); | |||
break; | |||
} | |||
} | |||
fireFileEvent(name: string, file: DraftPatchFileChange, showOptions?: TextDocumentShowOptions) { | |||
const event = new CustomEvent(name, { | |||
detail: { ...file, showOptions: showOptions }, | |||
}); | |||
this.dispatchEvent(event); | |||
} | |||
onCompareWorking(e: CustomEvent<TreeItemActionDetail>) { | |||
if (!e.detail.context) return; | |||
const [file] = e.detail.context; | |||
this.fireEvent('gl-patch-file-compare-working', { | |||
...file, | |||
showOptions: { | |||
preview: false, | |||
viewColumn: e.detail.altKey ? BesideViewColumn : undefined, | |||
}, | |||
}); | |||
} | |||
onOpenFile(e: CustomEvent<TreeItemActionDetail>) { | |||
if (!e.detail.context) return; | |||
const [file] = e.detail.context; | |||
this.fireEvent('gl-patch-file-open', { | |||
...file, | |||
showOptions: { | |||
preview: false, | |||
viewColumn: e.detail.altKey ? BesideViewColumn : undefined, | |||
}, | |||
}); | |||
} | |||
override onTreeItemChecked(e: CustomEvent<TreeItemCheckedDetail>) { | |||
if (!e.detail.context) return; | |||
const [gkRepositoryId] = e.detail.context; | |||
const patch = this.state.draft?.patches?.find(p => p.gkRepositoryId === gkRepositoryId); | |||
if (!patch) return; | |||
const selectedIndex = this.selectedPatches.indexOf(patch?.id); | |||
if (e.detail.checked) { | |||
if (selectedIndex === -1) { | |||
this.selectedPatches.push(patch.id); | |||
this.validityMessage = undefined; | |||
} | |||
} else if (selectedIndex > -1) { | |||
this.selectedPatches.splice(selectedIndex, 1); | |||
} | |||
// const [repoPath] = e.detail.context; | |||
// const event = new CustomEvent('repo-checked', { | |||
// detail: { | |||
// path: repoPath, | |||
// }, | |||
// }); | |||
// this.dispatchEvent(event); | |||
} | |||
override onTreeItemSelected(e: CustomEvent<TreeItemSelectionDetail>) { | |||
if (!e.detail.context) return; | |||
const [file] = e.detail.context; | |||
this.fireEvent('gl-patch-file-compare-previous', { ...file }); | |||
} | |||
onApplyPatch(e?: MouseEvent | KeyboardEvent, target: 'current' | 'branch' | 'worktree' = 'current') { | |||
if (this.canSubmit === false) { | |||
this.validityMessage = 'Please select changes to apply'; | |||
return; | |||
} | |||
this.validityMessage = undefined; | |||
this.fireEvent('gl-patch-apply-patch', { | |||
draft: this.state.draft!, | |||
target: target, | |||
selectedPatches: this.selectedPatches, | |||
}); | |||
} | |||
onSelectApplyOption(e: CustomEvent<{ target: MenuItem }>) { | |||
if (this.canSubmit === false) return; | |||
const target = e.detail?.target; | |||
if (target?.dataset?.value != null) { | |||
this.onApplyPatch(undefined, target.dataset.value as 'current' | 'branch' | 'worktree'); | |||
} | |||
} | |||
onChangePatchBase(_e?: MouseEvent | KeyboardEvent) { | |||
const evt = new CustomEvent<ChangePatchBaseDetail>('change-patch-base', { | |||
detail: { | |||
draft: this.state.draft!, | |||
}, | |||
}); | |||
this.dispatchEvent(evt); | |||
} | |||
onSelectPatchRepo(_e?: MouseEvent | KeyboardEvent) { | |||
const evt = new CustomEvent<SelectPatchRepoDetail>('select-patch-repo', { | |||
detail: { | |||
draft: this.state.draft!, | |||
}, | |||
}); | |||
this.dispatchEvent(evt); | |||
} | |||
onShowInGraph(_e?: MouseEvent | KeyboardEvent) { | |||
this.fireEvent('gl-patch-details-graph-show-patch', { draft: this.state.draft! }); | |||
} | |||
onCopyCloudLink() { | |||
this.fireEvent('gl-patch-details-copy-cloud-link', { draft: this.state.draft! }); | |||
} | |||
onShareLocalPatch() { | |||
this.fireEvent('gl-patch-details-share-local-patch', { draft: this.state.draft! }); | |||
} | |||
draftPatchToTreeModel( | |||
patch: NonNullable<DraftDetails['patches']>[0], | |||
isTree = false, | |||
compact = true, | |||
options?: Partial<TreeItemBase>, | |||
): TreeModel { | |||
const model = this.repoToTreeModel(patch.repository.name, patch.gkRepositoryId, options); | |||
if (!patch.files?.length) return model; | |||
const children = []; | |||
if (isTree) { | |||
const fileTree = makeHierarchical( | |||
patch.files, | |||
n => n.path.split('/'), | |||
(...parts: string[]) => parts.join('/'), | |||
compact, | |||
); | |||
if (fileTree.children != null) { | |||
for (const child of fileTree.children.values()) { | |||
const childModel = this.walkFileTree(child, { level: 2 }); | |||
children.push(childModel); | |||
} | |||
} | |||
} else { | |||
for (const file of patch.files) { | |||
const child = this.fileToTreeModel(file, { level: 2, branch: false }, true); | |||
children.push(child); | |||
} | |||
} | |||
if (children.length > 0) { | |||
model.branch = true; | |||
model.children = children; | |||
} | |||
return model; | |||
} | |||
// override getRepoActions(_name: string, _path: string, _options?: Partial<TreeItemBase>) { | |||
// return [ | |||
// { | |||
// icon: 'cloud-download', | |||
// label: 'Apply...', | |||
// action: 'apply-patch', | |||
// }, | |||
// // { | |||
// // icon: 'git-commit', | |||
// // label: 'Change Base', | |||
// // action: 'change-patch-base', | |||
// // }, | |||
// { | |||
// icon: 'gl-graph', | |||
// label: 'Open in Commit Graph', | |||
// action: 'show-patch-in-graph', | |||
// }, | |||
// ]; | |||
// } | |||
override getFileActions(_file: DraftPatchFileChange, _options?: Partial<TreeItemBase>) { | |||
return [ | |||
{ | |||
icon: 'go-to-file', | |||
label: 'Open file', | |||
action: 'file-open', | |||
}, | |||
{ | |||
icon: 'git-compare', | |||
label: 'Open Changes with Working File', | |||
action: 'file-compare-working', | |||
}, | |||
]; | |||
} | |||
} | |||
declare global { | |||
interface HTMLElementTagNameMap { | |||
'gl-patch-details': GlDraftDetails; | |||
} | |||
interface WindowEventMap { | |||
'gl-patch-apply-patch': CustomEvent<ApplyPatchDetail>; | |||
'gl-patch-details-graph-show-patch': CustomEvent<{ draft: DraftDetails }>; | |||
'gl-patch-details-share-local-patch': CustomEvent<{ draft: DraftDetails }>; | |||
'gl-patch-details-copy-cloud-link': CustomEvent<{ draft: DraftDetails }>; | |||
'gl-patch-file-compare-previous': CustomEvent<FileActionParams>; | |||
'gl-patch-file-compare-working': CustomEvent<FileActionParams>; | |||
'gl-patch-file-open': CustomEvent<FileActionParams>; | |||
} | |||
} |
@ -0,0 +1,523 @@ | |||
import { defineGkElement, Menu, MenuItem, Popover } from '@gitkraken/shared-web-components'; | |||
import { html } from 'lit'; | |||
import { customElement, property, query, state } from 'lit/decorators.js'; | |||
import { when } from 'lit/directives/when.js'; | |||
import type { GitFileChangeShape } from '../../../../../git/models/file'; | |||
import type { Change, FileActionParams, State } from '../../../../../plus/webviews/patchDetails/protocol'; | |||
import { flatCount } from '../../../../../system/iterable'; | |||
import type { Serialized } from '../../../../../system/serialize'; | |||
import type { | |||
TreeItemActionDetail, | |||
TreeItemBase, | |||
TreeItemCheckedDetail, | |||
TreeItemSelectionDetail, | |||
TreeModel, | |||
} from '../../../shared/components/tree/base'; | |||
import { GlTreeBase } from './gl-tree-base'; | |||
import '../../../shared/components/actions/action-nav'; | |||
import '../../../shared/components/button'; | |||
import '../../../shared/components/code-icon'; | |||
import '../../../shared/components/commit/commit-stats'; | |||
import '../../../shared/components/webview-pane'; | |||
export interface CreatePatchEventDetail { | |||
title: string; | |||
description?: string; | |||
changesets: Record<string, Change>; | |||
} | |||
export interface CreatePatchMetadataEventDetail { | |||
title: string; | |||
description: string | undefined; | |||
} | |||
export interface CreatePatchCheckRepositoryEventDetail { | |||
repoUri: string; | |||
checked: boolean | 'staged'; | |||
} | |||
// Can only import types from 'vscode' | |||
const BesideViewColumn = -2; /*ViewColumn.Beside*/ | |||
export type GlPatchCreateEvents = { | |||
[K in Extract<keyof WindowEventMap, `gl-patch-${string}` | `gl-patch-create-${string}`>]: WindowEventMap[K]; | |||
}; | |||
@customElement('gl-patch-create') | |||
export class GlPatchCreate extends GlTreeBase<GlPatchCreateEvents> { | |||
@property({ type: Object }) state?: Serialized<State>; | |||
// @state() | |||
// patchTitle = this.create.title ?? ''; | |||
// @state() | |||
// description = this.create.description ?? ''; | |||
@query('#title') | |||
titleInput!: HTMLInputElement; | |||
@query('#desc') | |||
descInput!: HTMLInputElement; | |||
@state() | |||
validityMessage?: string; | |||
get create() { | |||
return this.state!.create!; | |||
} | |||
get createChanges() { | |||
return Object.values(this.create.changes); | |||
} | |||
get createEntries() { | |||
return Object.entries(this.create.changes); | |||
} | |||
get hasWipChanges() { | |||
return this.createChanges.some(change => change?.type === 'wip'); | |||
} | |||
get selectedChanges(): [string, Change][] { | |||
if (this.createChanges.length === 1) return this.createEntries; | |||
return this.createEntries.filter(([, change]) => change.checked !== false); | |||
} | |||
get canSubmit() { | |||
return this.create.title != null && this.create.title.length > 0 && this.selectedChanges.length > 0; | |||
} | |||
get fileLayout() { | |||
return this.state?.preferences?.files?.layout ?? 'auto'; | |||
} | |||
get isCompact() { | |||
return this.state?.preferences?.files?.compact ?? true; | |||
} | |||
get filesModified() { | |||
return flatCount(this.createChanges, c => c.files?.length ?? 0); | |||
} | |||
constructor() { | |||
super(); | |||
defineGkElement(Menu, MenuItem, Popover); | |||
} | |||
renderForm() { | |||
return html` | |||
<div class="section"> | |||
${when( | |||
this.state?.create?.creationError != null, | |||
() => | |||
html` <div class="alert alert--error"> | |||
<code-icon icon="error"></code-icon> | |||
<p class="alert__content">${this.state!.create!.creationError}</p> | |||
</div>`, | |||
)} | |||
<div class="message-input"> | |||
<input id="title" type="text" class="message-input__control" placeholder="Title (required)" .value=${ | |||
this.create.title ?? '' | |||
} @input=${this.onTitleInput}></textarea> | |||
</div> | |||
<div class="message-input"> | |||
<textarea id="desc" class="message-input__control" placeholder="Description (optional)" .value=${ | |||
this.create.description ?? '' | |||
} @input=${this.onDescriptionInput}></textarea> | |||
</div> | |||
<p class="button-container"> | |||
<span class="button-group button-group--single"> | |||
<gl-button full @click=${this.onCreateAll}>Create Cloud Patch</gl-button> | |||
</span> | |||
</p> | |||
<!-- <p class="h-deemphasize"><code-icon icon="account"></code-icon> Requires an account <a href="#">sign-in</a></p> | |||
<p class="h-deemphasize"><code-icon icon="info"></code-icon> <a href="#">Learn more about cloud patches</a></p> --> | |||
</div> | |||
`; | |||
} | |||
// <gl-create-details | |||
// .repoChanges=${this.repoChanges} | |||
// .preferences=${this.state?.preferences} | |||
// .isUncommitted=${true} | |||
// @changeset-repo-checked=${this.onRepoChecked} | |||
// @changeset-unstaged-checked=${this.onUnstagedChecked} | |||
// > | |||
// </gl-create-details> | |||
override render() { | |||
return html` | |||
<div class="pane-groups"> | |||
<div class="pane-groups__group">${this.renderChangedFiles()}</div> | |||
<div class="pane-groups__group-fixed pane-groups__group--bottom"> | |||
<webview-pane expanded | |||
><span slot="title">Create Patch</span | |||
><span slot="subtitle">PREVIEW ☁️</span>${this.renderForm()}</webview-pane | |||
> | |||
</div> | |||
</div> | |||
`; | |||
} | |||
private renderChangedFiles() { | |||
return html` | |||
<webview-pane expanded> | |||
<span slot="title">Changes to Include</span> | |||
<action-nav slot="actions">${this.renderLayoutAction(this.fileLayout)}</action-nav> | |||
${when( | |||
this.validityMessage != null, | |||
() => | |||
html`<div class="section"> | |||
<div class="alert alert--error"> | |||
<code-icon icon="error"></code-icon> | |||
<p class="alert__content">${this.validityMessage}</p> | |||
</div> | |||
</div>`, | |||
)} | |||
<div class="change-list" data-region="files"> | |||
${when( | |||
this.create.changes == null, | |||
() => this.renderLoading(), | |||
() => this.renderTreeViewWithModel(), | |||
)} | |||
</div> | |||
</webview-pane> | |||
`; | |||
} | |||
// private renderChangeStats() { | |||
// if (this.filesModified == null) return undefined; | |||
// return html`<commit-stats | |||
// .added=${undefined} | |||
// modified="${this.filesModified}" | |||
// .removed=${undefined} | |||
// ></commit-stats>`; | |||
// } | |||
override onTreeItemChecked(e: CustomEvent<TreeItemCheckedDetail>) { | |||
console.log(e); | |||
// this.onRepoChecked() | |||
if (e.detail.context == null || e.detail.context.length < 1) return; | |||
const [repoUri, type] = e.detail.context; | |||
let checked: boolean | 'staged' = e.detail.checked; | |||
if (type === 'unstaged') { | |||
checked = e.detail.checked ? true : 'staged'; | |||
} | |||
const change = this.getChangeForRepo(repoUri); | |||
if (change == null) { | |||
debugger; | |||
return; | |||
} | |||
if (change.checked === checked) return; | |||
change.checked = checked; | |||
this.requestUpdate('state'); | |||
this.fireEvent('gl-patch-create-repo-checked', { | |||
repoUri: repoUri, | |||
checked: checked, | |||
}); | |||
} | |||
override onTreeItemSelected(e: CustomEvent<TreeItemSelectionDetail>) { | |||
if (!e.detail.context) return; | |||
const [file] = e.detail.context; | |||
this.fireEvent('gl-patch-file-compare-previous', { ...file }); | |||
} | |||
private renderTreeViewWithModel() { | |||
if (this.createChanges == null || this.createChanges.length === 0) { | |||
return this.renderTreeView([ | |||
{ | |||
label: 'No changes', | |||
path: '', | |||
level: 1, | |||
branch: false, | |||
checkable: false, | |||
expanded: true, | |||
checked: false, | |||
}, | |||
]); | |||
} | |||
const treeModel: TreeModel[] = []; | |||
// for knowing if we need to show repos | |||
const isCheckable = this.createChanges.length > 1; | |||
const isTree = this.isTree(this.filesModified ?? 0); | |||
const compact = this.isCompact; | |||
if (isCheckable) { | |||
for (const changeset of this.createChanges) { | |||
const tree = this.getTreeForChange(changeset, true, isTree, compact); | |||
if (tree != null) { | |||
treeModel.push(...tree); | |||
} | |||
} | |||
} else { | |||
const changeset = this.createChanges[0]; | |||
const tree = this.getTreeForChange(changeset, false, isTree, compact); | |||
if (tree != null) { | |||
treeModel.push(...tree); | |||
} | |||
} | |||
return this.renderTreeView(treeModel, this.state?.preferences?.indentGuides); | |||
} | |||
private getTreeForChange(change: Change, isMulti = false, isTree = false, compact = true): TreeModel[] | undefined { | |||
if (change.files == null || change.files.length === 0) return undefined; | |||
const children = []; | |||
if (change.type === 'wip') { | |||
const staged: Change['files'] = []; | |||
const unstaged: Change['files'] = []; | |||
for (const f of change.files) { | |||
if (f.staged) { | |||
staged.push(f); | |||
} else { | |||
unstaged.push(f); | |||
} | |||
} | |||
if (staged.length === 0 || unstaged.length === 0) { | |||
children.push(...this.renderFiles(change.files, isTree, compact, isMulti ? 2 : 1)); | |||
} else { | |||
if (unstaged.length) { | |||
children.push({ | |||
label: 'Unstaged Changes', | |||
path: '', | |||
level: isMulti ? 2 : 1, | |||
branch: true, | |||
checkable: true, | |||
expanded: true, | |||
checked: change.checked === true, | |||
context: [change.repository.uri, 'unstaged'], | |||
children: this.renderFiles(unstaged, isTree, compact, isMulti ? 3 : 2), | |||
}); | |||
} | |||
if (staged.length) { | |||
children.push({ | |||
label: 'Staged Changes', | |||
path: '', | |||
level: isMulti ? 2 : 1, | |||
branch: true, | |||
checkable: true, | |||
expanded: true, | |||
checked: change.checked !== false, | |||
disableCheck: true, | |||
children: this.renderFiles(staged, isTree, compact, isMulti ? 3 : 2), | |||
}); | |||
} | |||
} | |||
} else { | |||
children.push(...this.renderFiles(change.files, isTree, compact)); | |||
} | |||
if (!isMulti) { | |||
return children; | |||
} | |||
const repoModel = this.repoToTreeModel(change.repository.name, change.repository.uri, { | |||
branch: true, | |||
checkable: true, | |||
checked: change.checked !== false, | |||
}); | |||
repoModel.children = children; | |||
return [repoModel]; | |||
} | |||
private isTree(count: number) { | |||
if (this.fileLayout === 'auto') { | |||
return count > (this.state?.preferences?.files?.threshold ?? 5); | |||
} | |||
return this.fileLayout === 'tree'; | |||
} | |||
private createPatch() { | |||
if (!this.canSubmit) { | |||
// TODO: show error | |||
if (this.titleInput.value.length === 0) { | |||
this.titleInput.setCustomValidity('Title is required'); | |||
this.titleInput.reportValidity(); | |||
this.titleInput.focus(); | |||
} else { | |||
this.titleInput.setCustomValidity(''); | |||
} | |||
if (this.selectedChanges == null || this.selectedChanges.length === 0) { | |||
this.validityMessage = 'Check at least one change'; | |||
} else { | |||
this.validityMessage = undefined; | |||
} | |||
return; | |||
} | |||
this.validityMessage = undefined; | |||
this.titleInput.setCustomValidity(''); | |||
const changes = this.selectedChanges.reduce<Record<string, Change>>((a, [id, change]) => { | |||
a[id] = change; | |||
return a; | |||
}, {}); | |||
const patch = { | |||
title: this.create.title ?? '', | |||
description: this.create.description, | |||
changesets: changes, | |||
}; | |||
this.fireEvent('gl-patch-create-patch', patch); | |||
} | |||
private onCreateAll(_e: Event) { | |||
// const change = this.create.[0]; | |||
// if (change == null) { | |||
// return; | |||
// } | |||
// this.createPatch([change]); | |||
this.createPatch(); | |||
} | |||
private onSelectCreateOption(_e: CustomEvent<{ target: MenuItem }>) { | |||
// const target = e.detail?.target; | |||
// const value = target?.dataset?.value as 'staged' | 'unstaged' | undefined; | |||
// const currentChange = this.create.[0]; | |||
// if (value == null || currentChange == null) { | |||
// return; | |||
// } | |||
// const change = { | |||
// ...currentChange, | |||
// files: currentChange.files.filter(file => { | |||
// const staged = file.staged ?? false; | |||
// return (staged && value === 'staged') || (!staged && value === 'unstaged'); | |||
// }), | |||
// }; | |||
// this.createPatch([change]); | |||
} | |||
private getChangeForRepo(repoUri: string): Change | undefined { | |||
return this.create.changes[repoUri]; | |||
// for (const [id, change] of this.createEntries) { | |||
// if (change.repository.uri === repoUri) return change; | |||
// } | |||
// return undefined; | |||
} | |||
// private onRepoChecked(e: CustomEvent<{ repoUri: string; checked: boolean }>) { | |||
// const [_, changeset] = this.getRepoChangeSet(e.detail.repoUri); | |||
// if ((changeset as RepoWipChangeSet).checked === e.detail.checked) { | |||
// return; | |||
// } | |||
// (changeset as RepoWipChangeSet).checked = e.detail.checked; | |||
// this.requestUpdate('state'); | |||
// } | |||
// private onUnstagedChecked(e: CustomEvent<{ repoUri: string; checked: boolean | 'staged' }>) { | |||
// const [_, changeset] = this.getRepoChangeSet(e.detail.repoUri); | |||
// if ((changeset as RepoWipChangeSet).checked === e.detail.checked) { | |||
// return; | |||
// } | |||
// (changeset as RepoWipChangeSet).checked = e.detail.checked; | |||
// this.requestUpdate('state'); | |||
// } | |||
private onTitleInput(e: InputEvent) { | |||
this.create.title = (e.target as HTMLInputElement).value; | |||
this.fireEvent('gl-patch-create-update-metadata', { | |||
title: this.create.title, | |||
description: this.create.description, | |||
}); | |||
} | |||
private onDescriptionInput(e: InputEvent) { | |||
this.create.description = (e.target as HTMLInputElement).value; | |||
this.fireEvent('gl-patch-create-update-metadata', { | |||
title: this.create.title!, | |||
description: this.create.description, | |||
}); | |||
} | |||
protected override createRenderRoot() { | |||
return this; | |||
} | |||
override onTreeItemActionClicked(e: CustomEvent<TreeItemActionDetail>) { | |||
if (!e.detail.context || !e.detail.action) return; | |||
const action = e.detail.action; | |||
switch (action.action) { | |||
case 'show-patch-in-graph': | |||
this.onShowInGraph(e); | |||
break; | |||
case 'file-open': | |||
this.onOpenFile(e); | |||
break; | |||
} | |||
} | |||
onOpenFile(e: CustomEvent<TreeItemActionDetail>) { | |||
if (!e.detail.context) return; | |||
const [file] = e.detail.context; | |||
this.fireEvent('gl-patch-file-open', { | |||
...file, | |||
showOptions: { | |||
preview: false, | |||
viewColumn: e.detail.altKey ? BesideViewColumn : undefined, | |||
}, | |||
}); | |||
} | |||
onShowInGraph(_e: CustomEvent<TreeItemActionDetail>) { | |||
// this.fireEvent('gl-patch-details-graph-show-patch', { draft: this.state!.create! }); | |||
} | |||
override getFileActions(_file: GitFileChangeShape, _options?: Partial<TreeItemBase>) { | |||
return [ | |||
{ | |||
icon: 'go-to-file', | |||
label: 'Open file', | |||
action: 'file-open', | |||
}, | |||
]; | |||
} | |||
override getRepoActions(_name: string, _path: string, _options?: Partial<TreeItemBase>) { | |||
return [ | |||
{ | |||
icon: 'gl-graph', | |||
label: 'Open in Commit Graph', | |||
action: 'show-patch-in-graph', | |||
}, | |||
]; | |||
} | |||
} | |||
declare global { | |||
interface HTMLElementTagNameMap { | |||
'gl-patch-create': GlPatchCreate; | |||
} | |||
interface WindowEventMap { | |||
'gl-patch-create-repo-checked': CustomEvent<CreatePatchCheckRepositoryEventDetail>; | |||
'gl-patch-create-patch': CustomEvent<CreatePatchEventDetail>; | |||
'gl-patch-create-update-metadata': CustomEvent<CreatePatchMetadataEventDetail>; | |||
'gl-patch-file-compare-previous': CustomEvent<FileActionParams>; | |||
'gl-patch-file-compare-working': CustomEvent<FileActionParams>; | |||
'gl-patch-file-open': CustomEvent<FileActionParams>; | |||
// 'gl-patch-details-graph-show-patch': CustomEvent<{ draft: State['create'] }>; | |||
} | |||
} |
@ -0,0 +1,209 @@ | |||
import { html, nothing } from 'lit'; | |||
import type { GitFileChangeShape } from '../../../../../git/models/file'; | |||
import type { HierarchicalItem } from '../../../../../system/array'; | |||
import { makeHierarchical } from '../../../../../system/array'; | |||
import type { GlEvents } from '../../../shared/components/element'; | |||
import { GlElement } from '../../../shared/components/element'; | |||
import type { | |||
TreeItemAction, | |||
TreeItemActionDetail, | |||
TreeItemBase, | |||
TreeItemCheckedDetail, | |||
TreeItemSelectionDetail, | |||
TreeModel, | |||
} from '../../../shared/components/tree/base'; | |||
import '../../../shared/components/tree/tree-generator'; | |||
import '../../../shared/components/skeleton-loader'; | |||
import '../../../shared/components/actions/action-item'; | |||
export class GlTreeBase<Events extends GlEvents = GlEvents> extends GlElement<Events> { | |||
protected onTreeItemActionClicked?(_e: CustomEvent<TreeItemActionDetail>): void; | |||
protected onTreeItemChecked?(_e: CustomEvent<TreeItemCheckedDetail>): void; | |||
protected onTreeItemSelected?(_e: CustomEvent<TreeItemSelectionDetail>): void; | |||
protected renderLoading() { | |||
return html` | |||
<div class="section section--skeleton"> | |||
<skeleton-loader></skeleton-loader> | |||
</div> | |||
<div class="section section--skeleton"> | |||
<skeleton-loader></skeleton-loader> | |||
</div> | |||
<div class="section section--skeleton"> | |||
<skeleton-loader></skeleton-loader> | |||
</div> | |||
`; | |||
} | |||
protected renderLayoutAction(layout: string) { | |||
if (!layout) return nothing; | |||
let value = 'tree'; | |||
let icon = 'list-tree'; | |||
let label = 'View as Tree'; | |||
switch (layout) { | |||
case 'auto': | |||
value = 'list'; | |||
icon = 'gl-list-auto'; | |||
label = 'View as List'; | |||
break; | |||
case 'list': | |||
value = 'tree'; | |||
icon = 'list-flat'; | |||
label = 'View as Tree'; | |||
break; | |||
case 'tree': | |||
value = 'auto'; | |||
icon = 'list-tree'; | |||
label = 'View as Auto'; | |||
break; | |||
} | |||
return html`<action-item data-switch-value="${value}" label="${label}" icon="${icon}"></action-item>`; | |||
} | |||
protected renderTreeView(treeModel: TreeModel[], guides: 'none' | 'onHover' | 'always' = 'none') { | |||
return html`<gl-tree-generator | |||
.model=${treeModel} | |||
.guides=${guides} | |||
@gl-tree-generated-item-action-clicked=${this.onTreeItemActionClicked} | |||
@gl-tree-generated-item-checked=${this.onTreeItemChecked} | |||
@gl-tree-generated-item-selected=${this.onTreeItemSelected} | |||
></gl-tree-generator>`; | |||
} | |||
protected renderFiles(files: GitFileChangeShape[], isTree = false, compact = false, level = 2): TreeModel[] { | |||
const children: TreeModel[] = []; | |||
if (isTree) { | |||
const fileTree = makeHierarchical( | |||
files, | |||
n => n.path.split('/'), | |||
(...parts: string[]) => parts.join('/'), | |||
compact, | |||
); | |||
if (fileTree.children != null) { | |||
for (const child of fileTree.children.values()) { | |||
const childModel = this.walkFileTree(child, { level: level }); | |||
children.push(childModel); | |||
} | |||
} | |||
} else { | |||
for (const file of files) { | |||
const child = this.fileToTreeModel(file, { level: level, branch: false }, true); | |||
children.push(child); | |||
} | |||
} | |||
return children; | |||
} | |||
protected walkFileTree( | |||
item: HierarchicalItem<GitFileChangeShape>, | |||
options: Partial<TreeItemBase> = { level: 1 }, | |||
): TreeModel { | |||
if (options.level === undefined) { | |||
options.level = 1; | |||
} | |||
let model: TreeModel; | |||
if (item.value == null) { | |||
model = this.folderToTreeModel(item.name, options); | |||
} else { | |||
model = this.fileToTreeModel(item.value, options); | |||
} | |||
if (item.children != null) { | |||
const children = []; | |||
for (const child of item.children.values()) { | |||
const childModel = this.walkFileTree(child, { ...options, level: options.level + 1 }); | |||
children.push(childModel); | |||
} | |||
if (children.length > 0) { | |||
model.branch = true; | |||
model.children = children; | |||
} | |||
} | |||
return model; | |||
} | |||
protected folderToTreeModel(name: string, options?: Partial<TreeItemBase>): TreeModel { | |||
return { | |||
branch: false, | |||
expanded: true, | |||
path: name, | |||
level: 1, | |||
checkable: false, | |||
checked: false, | |||
icon: 'folder', | |||
label: name, | |||
...options, | |||
}; | |||
} | |||
protected getRepoActions(_name: string, _path: string, _options?: Partial<TreeItemBase>): TreeItemAction[] { | |||
return []; | |||
} | |||
protected emptyTreeModel(name: string, options?: Partial<TreeItemBase>): TreeModel { | |||
return { | |||
branch: false, | |||
expanded: true, | |||
path: '', | |||
level: 1, | |||
checkable: true, | |||
checked: true, | |||
icon: undefined, | |||
label: name, | |||
...options, | |||
}; | |||
} | |||
protected repoToTreeModel(name: string, path: string, options?: Partial<TreeItemBase>): TreeModel<string[]> { | |||
return { | |||
branch: false, | |||
expanded: true, | |||
path: path, | |||
level: 1, | |||
checkable: true, | |||
checked: true, | |||
icon: 'repo', | |||
label: name, | |||
context: [path], | |||
actions: this.getRepoActions(name, path, options), | |||
...options, | |||
}; | |||
} | |||
protected getFileActions(_file: GitFileChangeShape, _options?: Partial<TreeItemBase>): TreeItemAction[] { | |||
return []; | |||
} | |||
protected fileToTreeModel( | |||
file: GitFileChangeShape, | |||
options?: Partial<TreeItemBase>, | |||
flat = false, | |||
glue = '/', | |||
): TreeModel<GitFileChangeShape[]> { | |||
const pathIndex = file.path.lastIndexOf(glue); | |||
const fileName = pathIndex !== -1 ? file.path.substring(pathIndex + 1) : file.path; | |||
const filePath = flat && pathIndex !== -1 ? file.path.substring(0, pathIndex) : ''; | |||
return { | |||
branch: false, | |||
expanded: true, | |||
path: file.path, | |||
level: 1, | |||
checkable: false, | |||
checked: false, | |||
// icon: 'file', //{ type: 'status', name: file.status }, | |||
label: fileName, | |||
description: flat === true ? filePath : undefined, | |||
context: [file], | |||
actions: this.getFileActions(file, options), | |||
decorators: [{ type: 'text', label: file.status }], | |||
...options, | |||
}; | |||
} | |||
} |
@ -0,0 +1,182 @@ | |||
import { Badge, defineGkElement, Menu, MenuItem, Popover } from '@gitkraken/shared-web-components'; | |||
import { html, nothing } from 'lit'; | |||
import { customElement, property } from 'lit/decorators.js'; | |||
import { when } from 'lit/directives/when.js'; | |||
import type { DraftDetails, Mode, State } from '../../../../../plus/webviews/patchDetails/protocol'; | |||
import { GlElement } from '../../../shared/components/element'; | |||
import type { PatchDetailsApp } from '../patchDetails'; | |||
import './gl-draft-details'; | |||
import './gl-patch-create'; | |||
interface ExplainState { | |||
cancelled?: boolean; | |||
error?: { message: string }; | |||
summary?: string; | |||
} | |||
export interface ApplyPatchDetail { | |||
draft: DraftDetails; | |||
target?: 'current' | 'branch' | 'worktree'; | |||
base?: string; | |||
// [key: string]: unknown; | |||
} | |||
export interface ChangePatchBaseDetail { | |||
draft: DraftDetails; | |||
// [key: string]: unknown; | |||
} | |||
export interface SelectPatchRepoDetail { | |||
draft: DraftDetails; | |||
repoPath?: string; | |||
// [key: string]: unknown; | |||
} | |||
export interface ShowPatchInGraphDetail { | |||
draft: DraftDetails; | |||
// [key: string]: unknown; | |||
} | |||
export type GlPatchDetailsAppEvents = { | |||
[K in Extract<keyof WindowEventMap, `gl-patch-details-${string}`>]: WindowEventMap[K]; | |||
}; | |||
@customElement('gl-patch-details-app') | |||
export class GlPatchDetailsApp extends GlElement<GlPatchDetailsAppEvents> { | |||
@property({ type: Object }) | |||
state!: State; | |||
@property({ type: Object }) | |||
explain?: ExplainState; | |||
@property({ attribute: false, type: Object }) | |||
app?: PatchDetailsApp; | |||
constructor() { | |||
super(); | |||
defineGkElement(Badge, Popover, Menu, MenuItem); | |||
} | |||
get wipChangesCount() { | |||
if (this.state?.create == null) return 0; | |||
return Object.values(this.state.create.changes).reduce((a, c) => { | |||
a += c.files?.length ?? 0; | |||
return a; | |||
}, 0); | |||
} | |||
get wipChangeState() { | |||
if (this.state?.create == null) return undefined; | |||
const state = Object.values(this.state.create.changes).reduce( | |||
(a, c) => { | |||
if (c.files != null) { | |||
a.files += c.files.length; | |||
a.on.add(c.repository.uri); | |||
} | |||
return a; | |||
}, | |||
{ files: 0, on: new Set<string>() }, | |||
); | |||
// return file length total and repo/branch names | |||
return { | |||
count: state.files, | |||
branches: Array.from(state.on).join(', '), | |||
}; | |||
} | |||
get mode(): Mode { | |||
return this.state?.mode ?? 'view'; | |||
} | |||
private indentPreference = 16; | |||
private updateDocumentProperties() { | |||
const preference = this.state?.preferences?.indent; | |||
if (preference === this.indentPreference) return; | |||
this.indentPreference = preference ?? 16; | |||
const rootStyle = document.documentElement.style; | |||
rootStyle.setProperty('--gitlens-tree-indent', `${this.indentPreference}px`); | |||
} | |||
override updated(changedProperties: Map<string | number | symbol, unknown>) { | |||
if (changedProperties.has('state')) { | |||
this.updateDocumentProperties(); | |||
} | |||
} | |||
private renderTabs() { | |||
return nothing; | |||
// return html` | |||
// <nav class="details-tab"> | |||
// <button | |||
// class="details-tab__item ${this.mode === 'view' ? ' is-active' : ''}" | |||
// data-action="mode" | |||
// data-action-value="view" | |||
// > | |||
// Patch | |||
// </button> | |||
// <button | |||
// class="details-tab__item ${this.mode === 'create' ? ' is-active' : ''}" | |||
// data-action="mode" | |||
// data-action-value="create" | |||
// title="${this.wipChangeState != null | |||
// ? `${pluralize('file change', this.wipChangeState.count, { | |||
// plural: 'file changes', | |||
// })} on ${this.wipChangeState.branches}` | |||
// : nothing}" | |||
// > | |||
// Create${this.wipChangeState | |||
// ? html` <gk-badge variant="filled">${this.wipChangeState.count}</gk-badge>` | |||
// : ''} | |||
// </button> | |||
// </nav> | |||
// `; | |||
} | |||
override render() { | |||
return html` | |||
<div class="commit-detail-panel scrollable"> | |||
${this.renderTabs()} | |||
<main id="main" tabindex="-1"> | |||
${when( | |||
this.mode === 'view', | |||
() => html`<gl-draft-details .state=${this.state} .explain=${this.explain}></gl-draft-details>`, | |||
() => html`<gl-patch-create .state=${this.state}></gl-patch-create>`, | |||
)} | |||
</main> | |||
</div> | |||
`; | |||
} | |||
// onShowInGraph(e: CustomEvent<ShowPatchInGraphDetail>) { | |||
// this.fireEvent('gl-patch-details-graph-show-patch', e.detail); | |||
// } | |||
// private onShareLocalPatch(_e: CustomEvent<undefined>) { | |||
// this.fireEvent('gl-patch-details-share-local-patch'); | |||
// } | |||
// private onCopyCloudLink(_e: CustomEvent<undefined>) { | |||
// this.fireEvent('gl-patch-details-copy-cloud-link'); | |||
// } | |||
protected override createRenderRoot() { | |||
return this; | |||
} | |||
} | |||
declare global { | |||
interface HTMLElementTagNameMap { | |||
'gl-patch-details-app': GlPatchDetailsApp; | |||
} | |||
// interface WindowEventMap { | |||
// 'gl-patch-details-graph-show-patch': CustomEvent<ShowPatchInGraphDetail>; | |||
// 'gl-patch-details-share-local-patch': CustomEvent<undefined>; | |||
// 'gl-patch-details-copy-cloud-link': CustomEvent<undefined>; | |||
// } | |||
} |
@ -0,0 +1,27 @@ | |||
<!doctype html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="utf-8" /> | |||
<style nonce="#{cspNonce}"> | |||
@font-face { | |||
font-family: 'codicon'; | |||
font-display: block; | |||
src: url('#{webroot}/codicon.ttf?2ab61cbaefbdf4c7c5589068100bee0c') format('truetype'); | |||
} | |||
@font-face { | |||
font-family: 'glicons'; | |||
font-display: block; | |||
src: url('#{root}/dist/glicons.woff2?8e33f5a80a91b05940d687a08305c156') format('woff2'); | |||
} | |||
</style> | |||
</head> | |||
<body | |||
class="preload" | |||
data-placement="#{placement}" | |||
data-vscode-context='{ "preventDefaultContextMenuItems": true, "webview": "#{webviewId}" }' | |||
> | |||
<gl-patch-details-app id="app"></gl-patch-details-app> | |||
#{endOfBody} | |||
</body> | |||
</html> |
@ -0,0 +1,164 @@ | |||
@use '../../shared/styles/details-base'; | |||
.message-block__text strong:not(:only-child) { | |||
display: inline-block; | |||
margin-bottom: 0.52rem; | |||
} | |||
// TODO: "change-list__action" should be a separate component | |||
.change-list__action { | |||
box-sizing: border-box; | |||
display: inline-flex; | |||
justify-content: center; | |||
align-items: center; | |||
width: 2rem; | |||
height: 2rem; | |||
border-radius: 0.25em; | |||
color: inherit; | |||
padding: 2px; | |||
vertical-align: text-bottom; | |||
text-decoration: none; | |||
} | |||
.change-list__action:focus { | |||
outline: 1px solid var(--vscode-focusBorder); | |||
outline-offset: -1px; | |||
} | |||
.change-list__action:hover { | |||
color: inherit; | |||
background-color: var(--vscode-toolbar-hoverBackground); | |||
text-decoration: none; | |||
} | |||
.change-list__action:active { | |||
background-color: var(--vscode-toolbar-activeBackground); | |||
} | |||
.patch-base { | |||
display: flex; | |||
flex-direction: row; | |||
justify-content: flex-end; | |||
align-items: center; | |||
gap: 0.4rem; | |||
padding: { | |||
top: 0.1rem; | |||
bottom: 0.1rem; | |||
} | |||
:first-child { | |||
margin-right: auto; | |||
} | |||
} | |||
textarea.message-input__control { | |||
resize: vertical; | |||
min-height: 4rem; | |||
max-height: 40rem; | |||
} | |||
.message-input { | |||
padding-top: 0.8rem; | |||
&__control { | |||
border: 1px solid var(--vscode-input-border); | |||
background: var(--vscode-input-background); | |||
padding: 0.5rem; | |||
font-size: 1.3rem; | |||
line-height: 1.4; | |||
width: 100%; | |||
border-radius: 0.2rem; | |||
color: var(--vscode-input-foreground); | |||
font-family: inherit; | |||
&::placeholder { | |||
color: var(--vscode-input-placeholderForeground); | |||
} | |||
&:invalid { | |||
border-color: var(--vscode-inputValidation-errorBorder); | |||
background-color: var(--vscode-inputValidation-errorBackground); | |||
} | |||
&:focus { | |||
outline: 1px solid var(--vscode-focusBorder); | |||
outline-offset: -1px; | |||
} | |||
} | |||
} | |||
.h { | |||
&-spacing { | |||
margin-bottom: 1.5rem; | |||
} | |||
&-deemphasize { | |||
margin: 0.8rem 0 0.4rem; | |||
opacity: 0.8; | |||
} | |||
} | |||
.alert { | |||
display: flex; | |||
flex-direction: row; | |||
align-items: center; | |||
padding: 0.8rem 1.2rem; | |||
line-height: 1.2; | |||
background-color: var(--color-alert-errorBackground); | |||
border-left: 0.3rem solid var(--color-alert-errorBorder); | |||
color: var(--color-alert-foreground); | |||
code-icon { | |||
margin-right: 0.4rem; | |||
vertical-align: baseline; | |||
} | |||
&__content { | |||
font-size: 1.2rem; | |||
line-height: 1.2; | |||
text-align: left; | |||
margin: 0; | |||
} | |||
} | |||
.commit-detail-panel { | |||
height: 100vh; | |||
display: flex; | |||
flex-direction: column; | |||
overflow: hidden; | |||
} | |||
.details-tab { | |||
flex: none; | |||
} | |||
main { | |||
flex: 1 1 auto; | |||
overflow: hidden; | |||
} | |||
gl-patch-create { | |||
display: contents; | |||
} | |||
.pane-groups { | |||
display: flex; | |||
flex-direction: column; | |||
height: 100%; | |||
&__group { | |||
min-height: 0; | |||
flex: 1 1 auto; | |||
display: flex; | |||
flex-direction: column; | |||
overflow: hidden; | |||
webview-pane { | |||
flex: none; | |||
&[expanded] { | |||
min-height: 0; | |||
flex: 1; | |||
} | |||
} | |||
} | |||
&__group-fixed { | |||
flex: none; | |||
} | |||
} |
@ -0,0 +1,337 @@ | |||
/*global*/ | |||
import type { TextDocumentShowOptions } from 'vscode'; | |||
import type { ViewFilesLayout } from '../../../../config'; | |||
import type { DraftPatchFileChange } from '../../../../gk/models/drafts'; | |||
import type { State, SwitchModeParams } from '../../../../plus/webviews/patchDetails/protocol'; | |||
import { | |||
ApplyPatchCommandType, | |||
CopyCloudLinkCommandType, | |||
CreateFromLocalPatchCommandType, | |||
CreatePatchCommandType, | |||
DidChangeCreateNotificationType, | |||
DidChangeDraftNotificationType, | |||
DidChangeNotificationType, | |||
DidChangePreferencesNotificationType, | |||
DidExplainCommandType, | |||
ExplainCommandType, | |||
FileActionsCommandType, | |||
OpenFileCommandType, | |||
OpenFileComparePreviousCommandType, | |||
OpenFileCompareWorkingCommandType, | |||
OpenFileOnRemoteCommandType, | |||
SelectPatchBaseCommandType, | |||
SelectPatchRepoCommandType, | |||
SwitchModeCommandType, | |||
UpdateCreatePatchMetadataCommandType, | |||
UpdateCreatePatchRepositoryCheckedStateCommandType, | |||
UpdatePreferencesCommandType, | |||
} from '../../../../plus/webviews/patchDetails/protocol'; | |||
import type { Serialized } from '../../../../system/serialize'; | |||
import type { IpcMessage } from '../../../protocol'; | |||
import { ExecuteCommandType, onIpc } from '../../../protocol'; | |||
import { App } from '../../shared/appBase'; | |||
import { DOM } from '../../shared/dom'; | |||
import type { ApplyPatchDetail, GlDraftDetails } from './components/gl-draft-details'; | |||
import type { | |||
CreatePatchCheckRepositoryEventDetail, | |||
CreatePatchEventDetail, | |||
CreatePatchMetadataEventDetail, | |||
GlPatchCreate, | |||
} from './components/gl-patch-create'; | |||
import type { | |||
ChangePatchBaseDetail, | |||
GlPatchDetailsApp, | |||
SelectPatchRepoDetail, | |||
ShowPatchInGraphDetail, | |||
} from './components/patch-details-app'; | |||
import './patchDetails.scss'; | |||
import './components/patch-details-app'; | |||
export const uncommittedSha = '0000000000000000000000000000000000000000'; | |||
export interface FileChangeListItemDetail extends DraftPatchFileChange { | |||
showOptions?: TextDocumentShowOptions; | |||
} | |||
export class PatchDetailsApp extends App<Serialized<State>> { | |||
constructor() { | |||
super('PatchDetailsApp'); | |||
} | |||
override onInitialize() { | |||
this.attachState(); | |||
} | |||
override onBind() { | |||
const disposables = [ | |||
// DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-open-on-remote', e => | |||
// this.onOpenFileOnRemote(e.detail), | |||
// ), | |||
// DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-compare-working', e => | |||
// this.onCompareFileWithWorking(e.detail), | |||
// ), | |||
// DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-more-actions', e => | |||
// this.onFileMoreActions(e.detail), | |||
// ), | |||
DOM.on('[data-switch-value]', 'click', e => this.onToggleFilesLayout(e)), | |||
DOM.on('[data-action="ai-explain"]', 'click', e => this.onAIExplain(e)), | |||
DOM.on('[data-action="switch-ai"]', 'click', e => this.onSwitchAIModel(e)), | |||
DOM.on('[data-action="mode"]', 'click', e => this.onModeClicked(e)), | |||
DOM.on<GlDraftDetails, ApplyPatchDetail>('gl-draft-details', 'gl-patch-apply-patch', e => | |||
this.onApplyPatch(e.detail), | |||
), | |||
DOM.on<GlPatchDetailsApp, ChangePatchBaseDetail>('gl-patch-details-app', 'change-patch-base', e => | |||
this.onChangePatchBase(e.detail), | |||
), | |||
DOM.on<GlPatchDetailsApp, SelectPatchRepoDetail>('gl-patch-details-app', 'select-patch-repo', e => | |||
this.onSelectPatchRepo(e.detail), | |||
), | |||
DOM.on<GlPatchDetailsApp, ShowPatchInGraphDetail>( | |||
'gl-patch-details-app', | |||
'gl-patch-details-graph-show-patch', | |||
e => this.onShowPatchInGraph(e.detail), | |||
), | |||
DOM.on<GlPatchDetailsApp, CreatePatchEventDetail>('gl-patch-details-app', 'gl-patch-create-patch', e => | |||
this.onCreatePatch(e.detail), | |||
), | |||
DOM.on<GlPatchDetailsApp, undefined>('gl-patch-details-app', 'gl-patch-share-local-patch', () => | |||
this.onShareLocalPatch(), | |||
), | |||
DOM.on<GlPatchDetailsApp, undefined>('gl-patch-details-app', 'gl-patch-copy-cloud-link', () => | |||
this.onCopyCloudLink(), | |||
), | |||
DOM.on<GlPatchCreate, CreatePatchCheckRepositoryEventDetail>( | |||
'gl-patch-create', | |||
'gl-patch-create-repo-checked', | |||
e => this.onCreateCheckRepo(e.detail), | |||
), | |||
DOM.on<GlPatchCreate, CreatePatchMetadataEventDetail>( | |||
'gl-patch-create', | |||
'gl-patch-create-update-metadata', | |||
e => this.onCreateUpdateMetadata(e.detail), | |||
), | |||
DOM.on<GlPatchCreate, FileChangeListItemDetail>( | |||
'gl-patch-create,gl-draft-details', | |||
'gl-patch-file-compare-previous', | |||
e => this.onCompareFileWithPrevious(e.detail), | |||
), | |||
DOM.on<GlPatchCreate, FileChangeListItemDetail>( | |||
'gl-patch-create,gl-draft-details', | |||
'gl-patch-file-compare-working', | |||
e => this.onCompareFileWithWorking(e.detail), | |||
), | |||
DOM.on<GlDraftDetails, FileChangeListItemDetail>( | |||
'gl-patch-create,gl-draft-details', | |||
'gl-patch-file-open', | |||
e => this.onOpenFile(e.detail), | |||
), | |||
]; | |||
return disposables; | |||
} | |||
protected override onMessageReceived(e: MessageEvent) { | |||
const msg = e.data as IpcMessage; | |||
this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); | |||
switch (msg.method) { | |||
// case DidChangeRichStateNotificationType.method: | |||
// onIpc(DidChangeRichStateNotificationType, msg, params => { | |||
// if (this.state.selected == null) return; | |||
// assertsSerialized<typeof params>(params); | |||
// const newState = { ...this.state }; | |||
// if (params.formattedMessage != null) { | |||
// newState.selected!.message = params.formattedMessage; | |||
// } | |||
// // if (params.pullRequest != null) { | |||
// newState.pullRequest = params.pullRequest; | |||
// // } | |||
// // if (params.formattedMessage != null) { | |||
// newState.autolinkedIssues = params.autolinkedIssues; | |||
// // } | |||
// this.state = newState; | |||
// this.setState(this.state); | |||
// this.renderRichContent(); | |||
// }); | |||
// break; | |||
case DidChangeNotificationType.method: | |||
onIpc(DidChangeNotificationType, msg, params => { | |||
assertsSerialized<State>(params.state); | |||
this.state = params.state; | |||
this.setState(this.state); | |||
this.attachState(); | |||
}); | |||
break; | |||
case DidChangeCreateNotificationType.method: | |||
onIpc(DidChangeCreateNotificationType, msg, params => { | |||
// assertsSerialized<State>(params.state); | |||
this.state = { ...this.state, ...params }; | |||
this.setState(this.state); | |||
this.attachState(true); | |||
}); | |||
break; | |||
case DidChangeDraftNotificationType.method: | |||
onIpc(DidChangeDraftNotificationType, msg, params => { | |||
// assertsSerialized<State>(params.state); | |||
this.state = { ...this.state, ...params }; | |||
this.setState(this.state); | |||
this.attachState(true); | |||
}); | |||
break; | |||
case DidChangePreferencesNotificationType.method: | |||
onIpc(DidChangePreferencesNotificationType, msg, params => { | |||
// assertsSerialized<State>(params.state); | |||
this.state = { ...this.state, ...params }; | |||
this.setState(this.state); | |||
this.attachState(true); | |||
}); | |||
break; | |||
default: | |||
super.onMessageReceived?.(e); | |||
} | |||
} | |||
private onCreateCheckRepo(e: CreatePatchCheckRepositoryEventDetail) { | |||
this.sendCommand(UpdateCreatePatchRepositoryCheckedStateCommandType, e); | |||
} | |||
private onCreateUpdateMetadata(e: CreatePatchMetadataEventDetail) { | |||
this.sendCommand(UpdateCreatePatchMetadataCommandType, e); | |||
} | |||
private onShowPatchInGraph(_e: ShowPatchInGraphDetail) { | |||
// this.sendCommand(OpenInCommitGraphCommandType, { }); | |||
} | |||
private onCreatePatch(e: CreatePatchEventDetail) { | |||
this.sendCommand(CreatePatchCommandType, e); | |||
} | |||
private onShareLocalPatch() { | |||
this.sendCommand(CreateFromLocalPatchCommandType, undefined); | |||
} | |||
private onCopyCloudLink() { | |||
this.sendCommand(CopyCloudLinkCommandType, undefined); | |||
} | |||
private onModeClicked(e: Event) { | |||
const mode = ((e.target as HTMLElement)?.dataset.actionValue as SwitchModeParams['mode']) ?? undefined; | |||
if (mode === this.state.mode) return; | |||
this.sendCommand(SwitchModeCommandType, { mode: mode }); | |||
} | |||
private onApplyPatch(e: ApplyPatchDetail) { | |||
console.log('onApplyPatch', e); | |||
if (e.selectedPatches == null || e.selectedPatches.length === 0) return; | |||
this.sendCommand(ApplyPatchCommandType, { details: e.draft, selected: e.selectedPatches }); | |||
} | |||
private onChangePatchBase(e: ChangePatchBaseDetail) { | |||
console.log('onChangePatchBase', e); | |||
this.sendCommand(SelectPatchBaseCommandType, undefined); | |||
} | |||
private onSelectPatchRepo(e: SelectPatchRepoDetail) { | |||
console.log('onSelectPatchRepo', e); | |||
this.sendCommand(SelectPatchRepoCommandType, undefined); | |||
} | |||
private onCommandClickedCore(action?: string) { | |||
const command = action?.startsWith('command:') ? action.slice(8) : action; | |||
if (command == null) return; | |||
this.sendCommand(ExecuteCommandType, { command: command }); | |||
} | |||
private onSwitchAIModel(_e: MouseEvent) { | |||
this.onCommandClickedCore('gitlens.switchAIModel'); | |||
} | |||
async onAIExplain(_e: MouseEvent) { | |||
try { | |||
const result = await this.sendCommandWithCompletion(ExplainCommandType, undefined, DidExplainCommandType); | |||
if (result.error) { | |||
this.component.explain = { error: { message: result.error.message ?? 'Error retrieving content' } }; | |||
} else if (result.summary) { | |||
this.component.explain = { summary: result.summary }; | |||
} else { | |||
this.component.explain = undefined; | |||
} | |||
} catch (ex) { | |||
this.component.explain = { error: { message: 'Error retrieving content' } }; | |||
} | |||
} | |||
private onToggleFilesLayout(e: MouseEvent) { | |||
const layout = ((e.target as HTMLElement)?.dataset.switchValue as ViewFilesLayout) ?? undefined; | |||
if (layout === this.state.preferences.files?.layout) return; | |||
const files: State['preferences']['files'] = { | |||
...this.state.preferences.files, | |||
layout: layout ?? 'auto', | |||
compact: this.state.preferences.files?.compact ?? true, | |||
threshold: this.state.preferences.files?.threshold ?? 5, | |||
icon: this.state.preferences.files?.icon ?? 'type', | |||
}; | |||
this.state = { ...this.state, preferences: { ...this.state.preferences, files: files } }; | |||
this.attachState(); | |||
this.sendCommand(UpdatePreferencesCommandType, { files: files }); | |||
} | |||
private onOpenFileOnRemote(e: FileChangeListItemDetail) { | |||
this.sendCommand(OpenFileOnRemoteCommandType, e); | |||
} | |||
private onOpenFile(e: FileChangeListItemDetail) { | |||
this.sendCommand(OpenFileCommandType, e); | |||
} | |||
private onCompareFileWithWorking(e: FileChangeListItemDetail) { | |||
this.sendCommand(OpenFileCompareWorkingCommandType, e); | |||
} | |||
private onCompareFileWithPrevious(e: FileChangeListItemDetail) { | |||
this.sendCommand(OpenFileComparePreviousCommandType, e); | |||
} | |||
private onFileMoreActions(e: FileChangeListItemDetail) { | |||
this.sendCommand(FileActionsCommandType, e); | |||
} | |||
private _component?: GlPatchDetailsApp; | |||
private get component() { | |||
if (this._component == null) { | |||
this._component = (document.getElementById('app') as GlPatchDetailsApp)!; | |||
this._component.app = this; | |||
} | |||
return this._component; | |||
} | |||
attachState(_force?: boolean) { | |||
this.component.state = this.state!; | |||
// if (force) { | |||
// this.component.requestUpdate('state'); | |||
// } | |||
} | |||
} | |||
function assertsSerialized<T>(obj: unknown): asserts obj is Serialized<T> {} | |||
new PatchDetailsApp(); |
@ -0,0 +1,15 @@ | |||
import { LitElement } from 'lit'; | |||
export type GlEvents<Prefix extends string = ''> = Record<`gl-${Prefix}${string}`, CustomEvent>; | |||
type GlEventsUnwrapped<Events extends GlEvents> = { | |||
[P in Extract<keyof Events, `gl-${string}`>]: UnwrapCustomEvent<Events[P]>; | |||
}; | |||
export abstract class GlElement<Events extends GlEvents = GlEvents> extends LitElement { | |||
fireEvent<T extends keyof GlEventsUnwrapped<Events>>( | |||
name: T, | |||
detail?: GlEventsUnwrapped<Events>[T] | undefined, | |||
): boolean { | |||
return this.dispatchEvent(new CustomEvent<GlEventsUnwrapped<Events>[T]>(name, { detail: detail })); | |||
} | |||
} |
@ -0,0 +1,91 @@ | |||
import type { GitFileStatus } from '../../../../../git/models/file'; | |||
import type { DraftPatchFileChange } from '../../../../../gk/models/drafts'; | |||
export interface TreeItemBase { | |||
// node properties | |||
branch: boolean; | |||
expanded: boolean; | |||
path: string; | |||
// parent | |||
parentPath?: string; | |||
parentExpanded?: boolean; | |||
// depth | |||
level: number; | |||
// checkbox | |||
checkable: boolean; | |||
checked?: boolean; | |||
disableCheck?: boolean; | |||
} | |||
// TODO: add support for modifiers (ctrl, alt, shift, meta) | |||
export interface TreeItemAction { | |||
icon: string; | |||
label: string; | |||
action: string; | |||
arguments?: any[]; | |||
} | |||
export interface TreeItemDecoratorBase { | |||
type: string; | |||
label: string; | |||
} | |||
export interface TreeItemDecoratorIcon extends TreeItemDecoratorBase { | |||
type: 'icon'; | |||
icon: string; | |||
} | |||
export interface TreeItemDecoratorText extends TreeItemDecoratorBase { | |||
type: 'text'; | |||
} | |||
export interface TreeItemDecoratorStatus extends TreeItemDecoratorBase { | |||
type: 'indicator' | 'badge'; | |||
status: string; | |||
} | |||
export type TreeItemDecorator = TreeItemDecoratorText | TreeItemDecoratorIcon | TreeItemDecoratorStatus; | |||
interface TreeModelBase<Context = any[]> extends TreeItemBase { | |||
label: string; | |||
icon?: string | { type: 'status'; name: GitFileStatus }; | |||
description?: string; | |||
context?: Context; | |||
actions?: TreeItemAction[]; | |||
decorators?: TreeItemDecorator[]; | |||
contextData?: unknown; | |||
} | |||
export interface TreeModel<Context = any[]> extends TreeModelBase<Context> { | |||
children?: TreeModel<Context>[]; | |||
} | |||
export interface TreeModelFlat extends TreeModelBase { | |||
size: number; | |||
position: number; | |||
} | |||
export interface TreeItemSelectionDetail { | |||
node: TreeItemBase; | |||
context?: DraftPatchFileChange[]; | |||
dblClick: boolean; | |||
altKey: boolean; | |||
ctrlKey: boolean; | |||
metaKey: boolean; | |||
} | |||
export interface TreeItemActionDetail extends TreeItemSelectionDetail { | |||
action: TreeItemAction; | |||
} | |||
export interface TreeItemCheckedDetail { | |||
node: TreeItemBase; | |||
context?: string[]; | |||
checked: boolean; | |||
} | |||
// export function toStashTree(files: GitFileChangeShape[]): TreeModel {} | |||
// export function toWipTrees(files: GitFileChangeShape[]): TreeModel[] {} |
@ -0,0 +1,225 @@ | |||
import { css, html, nothing } from 'lit'; | |||
import { customElement, property, state } from 'lit/decorators.js'; | |||
import { when } from 'lit/directives/when.js'; | |||
import { GlElement } from '../element'; | |||
import type { GlGitStatus } from '../status/git-status'; | |||
import type { | |||
TreeItemAction, | |||
TreeItemActionDetail, | |||
TreeItemCheckedDetail, | |||
TreeItemSelectionDetail, | |||
TreeModel, | |||
TreeModelFlat, | |||
} from './base'; | |||
import '../actions/action-item'; | |||
import '../status/git-status'; | |||
import '../code-icon'; | |||
import './tree'; | |||
import './tree-item'; | |||
export type GlTreeGeneratorEvents = { | |||
[K in Extract<keyof WindowEventMap, `gl-tree-generated-item-${string}`>]: WindowEventMap[K]; | |||
}; | |||
@customElement('gl-tree-generator') | |||
export class GlTreeGenerator extends GlElement<GlTreeGeneratorEvents> { | |||
static override styles = css` | |||
:host { | |||
display: contents; | |||
} | |||
`; | |||
@state() | |||
treeItems?: TreeModelFlat[] = undefined; | |||
@property({ reflect: true }) | |||
guides?: 'none' | 'onHover' | 'always'; | |||
_model?: TreeModel[]; | |||
@property({ type: Array, attribute: false }) | |||
set model(value: TreeModel[] | undefined) { | |||
if (this._model === value) return; | |||
this._model = value; | |||
let treeItems: TreeModelFlat[] | undefined; | |||
if (this._model != null) { | |||
const size = this._model.length; | |||
treeItems = this._model.reduce<TreeModelFlat[]>((acc, node, index) => { | |||
acc.push(...flattenTree(node, size, index + 1)); | |||
return acc; | |||
}, []); | |||
} | |||
this.treeItems = treeItems; | |||
} | |||
get model() { | |||
return this._model; | |||
} | |||
private renderIcon(icon?: string | { type: 'status'; name: string }) { | |||
if (icon == null) return nothing; | |||
if (typeof icon === 'string') { | |||
return html`<code-icon slot="icon" icon=${icon}></code-icon>`; | |||
} | |||
if (icon.type !== 'status') { | |||
return nothing; | |||
} | |||
return html`<gl-git-status slot="icon" .status=${icon.name as GlGitStatus['status']}></gl-git-status>`; | |||
} | |||
private renderActions(model: TreeModelFlat) { | |||
const actions = model.actions; | |||
if (actions == null || actions.length === 0) return nothing; | |||
return actions.map(action => { | |||
return html`<action-item | |||
slot="actions" | |||
.icon=${action.icon} | |||
.label=${action.label} | |||
@click=${(e: MouseEvent) => this.onTreeItemActionClicked(e, model, action)} | |||
></action-item>`; | |||
}); | |||
} | |||
private renderDecorators(model: TreeModelFlat) { | |||
const decorators = model.decorators; | |||
if (decorators == null || decorators.length === 0) return nothing; | |||
return decorators.map(decorator => { | |||
if (decorator.type === 'icon') { | |||
return html`<code-icon | |||
slot="decorators" | |||
title="${decorator.label}" | |||
aria-label="${decorator.label}" | |||
.icon=${decorator.icon} | |||
></code-icon>`; | |||
} | |||
if (decorator.type === 'text') { | |||
return html`<span slot="decorators">${decorator.label}</span>`; | |||
} | |||
// TODO: implement badge and indicator decorators | |||
return undefined; | |||
}); | |||
} | |||
private renderTreeItem(model: TreeModelFlat) { | |||
return html`<gl-tree-item | |||
.branch=${model.branch} | |||
.expanded=${model.expanded} | |||
.path=${model.path} | |||
.parentPath=${model.parentPath} | |||
.parentExpanded=${model.parentExpanded} | |||
.level=${model.level} | |||
.size=${model.size} | |||
.position=${model.position} | |||
.checkable=${model.checkable} | |||
.checked=${model.checked ?? false} | |||
.disableCheck=${model.disableCheck ?? false} | |||
.showIcon=${model.icon != null} | |||
@gl-tree-item-selected=${(e: CustomEvent<TreeItemSelectionDetail>) => this.onTreeItemSelected(e, model)} | |||
@gl-tree-item-checked=${(e: CustomEvent<TreeItemCheckedDetail>) => this.onTreeItemChecked(e, model)} | |||
> | |||
${this.renderIcon(model.icon)} | |||
${model.label}${when( | |||
model.description != null, | |||
() => html`<span slot="description">${model.description}</span>`, | |||
)} | |||
${this.renderActions(model)} ${this.renderDecorators(model)} | |||
</gl-tree-item>`; | |||
} | |||
private renderTree(nodes?: TreeModelFlat[]) { | |||
return nodes?.map(node => this.renderTreeItem(node)); | |||
} | |||
override render() { | |||
return html`<gl-tree>${this.renderTree(this.treeItems)}</gl-tree>`; | |||
} | |||
private onTreeItemSelected(e: CustomEvent<TreeItemSelectionDetail>, model: TreeModelFlat) { | |||
e.stopPropagation(); | |||
this.fireEvent('gl-tree-generated-item-selected', { | |||
...e.detail, | |||
node: model, | |||
context: model.context, | |||
}); | |||
} | |||
private onTreeItemChecked(e: CustomEvent<TreeItemCheckedDetail>, model: TreeModelFlat) { | |||
e.stopPropagation(); | |||
this.fireEvent('gl-tree-generated-item-checked', { | |||
...e.detail, | |||
node: model, | |||
context: model.context, | |||
}); | |||
} | |||
private onTreeItemActionClicked(e: MouseEvent, model: TreeModelFlat, action: TreeItemAction) { | |||
e.stopPropagation(); | |||
this.fireEvent('gl-tree-generated-item-action-clicked', { | |||
node: model, | |||
context: model.context, | |||
action: action, | |||
dblClick: false, | |||
altKey: e.altKey, | |||
ctrlKey: e.ctrlKey, | |||
metaKey: e.metaKey, | |||
}); | |||
} | |||
} | |||
function flattenTree(tree: TreeModel, children: number = 1, position: number = 1): TreeModelFlat[] { | |||
// const node = Object.keys(tree).reduce<TreeModelFlat>( | |||
// (acc, key) => { | |||
// if (key !== 'children') { | |||
// const value = tree[key as keyof TreeModel]; | |||
// if (value != null) { | |||
// acc[key] = value; | |||
// } | |||
// } | |||
// return acc; | |||
// }, | |||
// { size: children, position: position }, | |||
// ); | |||
const node: Partial<TreeModelFlat> = { | |||
size: children, | |||
position: position, | |||
}; | |||
for (const [key, value] of Object.entries(tree)) { | |||
if (value == null || key === 'children') continue; | |||
node[key as keyof TreeModelFlat] = value; | |||
} | |||
const nodes = [node as TreeModelFlat]; | |||
if (tree.children != null && tree.children.length > 0) { | |||
const childSize = tree.children.length; | |||
for (let i = 0; i < childSize; i++) { | |||
nodes.push(...flattenTree(tree.children[i], childSize, i + 1)); | |||
} | |||
} | |||
return nodes; | |||
} | |||
declare global { | |||
interface HTMLElementTagNameMap { | |||
'gl-tree-generator': GlTreeGenerator; | |||
} | |||
interface WindowEventMap { | |||
'gl-tree-generated-item-action-clicked': CustomEvent<TreeItemActionDetail>; | |||
'gl-tree-generated-item-selected': CustomEvent<TreeItemSelectionDetail>; | |||
'gl-tree-generated-item-checked': CustomEvent<TreeItemCheckedDetail>; | |||
} | |||
} |
@ -0,0 +1,271 @@ | |||
import { html, nothing } from 'lit'; | |||
import { customElement, property, query, state } from 'lit/decorators.js'; | |||
import { when } from 'lit/directives/when.js'; | |||
import { GlElement } from '../element'; | |||
import type { TreeItemCheckedDetail, TreeItemSelectionDetail } from './base'; | |||
import { treeItemStyles } from './tree.css'; | |||
import '../actions/action-nav'; | |||
import '../code-icon'; | |||
export type GlTreeItemEvents = { | |||
[K in Extract<keyof WindowEventMap, `gl-tree-item-${string}`>]: WindowEventMap[K]; | |||
}; | |||
@customElement('gl-tree-item') | |||
export class GlTreeItem extends GlElement<GlTreeItemEvents> { | |||
static override styles = treeItemStyles; | |||
// node properties | |||
@property({ type: Boolean }) | |||
branch = false; | |||
@property({ type: Boolean }) | |||
expanded = true; | |||
@property({ type: String }) | |||
path = ''; | |||
// parent | |||
@property({ type: String, attribute: 'parent-path' }) | |||
parentPath?: string; | |||
@property({ type: Boolean, attribute: 'parent-expanded' }) | |||
parentExpanded?: boolean; | |||
// depth and siblings | |||
@property({ type: Number }) | |||
level = 0; | |||
@property({ type: Number }) | |||
size = 1; | |||
@property({ type: Number }) | |||
position = 1; | |||
// checkbox | |||
@property({ type: Boolean }) | |||
checkable = false; | |||
@property({ type: Boolean }) | |||
checked = false; | |||
@property({ type: Boolean }) | |||
disableCheck = false; | |||
@property({ type: Boolean }) | |||
showIcon = true; | |||
// state | |||
@state() | |||
selected = false; | |||
@state() | |||
focused = false; | |||
@query('#button') | |||
buttonEl!: HTMLButtonElement; | |||
get isHidden() { | |||
return this.parentExpanded === false || (!this.branch && !this.expanded); | |||
} | |||
override connectedCallback() { | |||
super.connectedCallback(); | |||
this.addEventListener('click', this.onComponentClickBound); | |||
} | |||
override disconnectedCallback() { | |||
super.disconnectedCallback(); | |||
this.removeEventListener('click', this.onComponentClickBound); | |||
} | |||
private onComponentClick(e: MouseEvent) { | |||
this.selectCore({ | |||
dblClick: false, | |||
altKey: e.altKey, | |||
}); | |||
this.buttonEl.focus(); | |||
} | |||
private onComponentClickBound = this.onComponentClick.bind(this); | |||
private updateAttrs(changedProperties: Map<string, any>, force = false) { | |||
if (changedProperties.has('expanded') || force) { | |||
this.setAttribute('aria-expanded', this.expanded.toString()); | |||
} | |||
if (changedProperties.has('parentExpanded') || force) { | |||
this.setAttribute('aria-hidden', this.isHidden.toString()); | |||
} | |||
if (changedProperties.has('selected') || force) { | |||
this.setAttribute('aria-selected', this.selected.toString()); | |||
} | |||
if (changedProperties.has('size') || force) { | |||
this.setAttribute('aria-setsize', this.size.toString()); | |||
} | |||
if (changedProperties.has('position') || force) { | |||
this.setAttribute('aria-posinset', this.position.toString()); | |||
} | |||
if (changedProperties.has('level') || force) { | |||
this.setAttribute('aria-level', this.level.toString()); | |||
} | |||
} | |||
override firstUpdated() { | |||
this.role = 'treeitem'; | |||
} | |||
override updated(changedProperties: Map<string, any>) { | |||
this.updateAttrs(changedProperties); | |||
} | |||
private renderBranching() { | |||
const connectors = this.level - 1; | |||
if (connectors < 1 && !this.branch) { | |||
return nothing; | |||
} | |||
const branching = []; | |||
if (connectors > 0) { | |||
for (let i = 0; i < connectors; i++) { | |||
branching.push(html`<span class="node node--connector"><code-icon name="blank"></code-icon></span>`); | |||
} | |||
} | |||
if (this.branch) { | |||
branching.push( | |||
html`<code-icon class="branch" icon="${this.expanded ? 'chevron-down' : 'chevron-right'}"></code-icon>`, | |||
); | |||
} | |||
return branching; | |||
} | |||
private renderCheckbox() { | |||
if (!this.checkable) { | |||
return nothing; | |||
} | |||
return html`<span class="checkbox" | |||
><input | |||
class="checkbox__input" | |||
id="checkbox" | |||
type="checkbox" | |||
.checked=${this.checked} | |||
?disabled=${this.disableCheck} | |||
@change=${this.onCheckboxChange} | |||
@click=${this.onCheckboxClick} /><code-icon icon="check" size="14" class="checkbox__check"></code-icon | |||
></span>`; | |||
} | |||
private renderActions() { | |||
return html`<action-nav class="actions"><slot name="actions"></slot></action-nav>`; | |||
} | |||
private renderDecorators() { | |||
return html`<slot name="decorators" class="decorators"></slot>`; | |||
} | |||
override render() { | |||
return html` | |||
${this.renderBranching()}${this.renderCheckbox()} | |||
<button | |||
id="button" | |||
class="item" | |||
type="button" | |||
@click=${this.onButtonClick} | |||
@dblclick=${this.onButtonDblClick} | |||
> | |||
${when(this.showIcon, () => html`<slot name="icon" class="icon"></slot>`)} | |||
<span class="text"> | |||
<slot class="main"></slot> | |||
<slot name="description" class="description"></slot> | |||
</span> | |||
</button> | |||
${this.renderActions()}${this.renderDecorators()} | |||
`; | |||
} | |||
private selectCore( | |||
modifiers?: { dblClick: boolean; altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean }, | |||
quiet = false, | |||
) { | |||
this.fireEvent('gl-tree-item-select'); | |||
if (this.branch) { | |||
this.expanded = !this.expanded; | |||
} | |||
this.selected = true; | |||
if (!quiet) { | |||
window.requestAnimationFrame(() => { | |||
this.fireEvent('gl-tree-item-selected', { | |||
node: this, | |||
dblClick: modifiers?.dblClick ?? false, | |||
altKey: modifiers?.altKey ?? false, | |||
ctrlKey: modifiers?.ctrlKey ?? false, | |||
metaKey: modifiers?.metaKey ?? false, | |||
}); | |||
}); | |||
} | |||
} | |||
select() { | |||
this.selectCore(undefined, true); | |||
} | |||
deselect() { | |||
this.selected = false; | |||
} | |||
override focus() { | |||
this.buttonEl.focus(); | |||
} | |||
onButtonClick(e: MouseEvent) { | |||
console.log('onButtonClick', e); | |||
e.stopPropagation(); | |||
this.selectCore({ | |||
dblClick: false, | |||
altKey: e.altKey, | |||
}); | |||
} | |||
onButtonDblClick(e: MouseEvent) { | |||
console.log('onButtonDblClick', e); | |||
e.stopPropagation(); | |||
this.selectCore({ | |||
dblClick: true, | |||
altKey: e.altKey, | |||
ctrlKey: e.ctrlKey, | |||
metaKey: e.metaKey, | |||
}); | |||
} | |||
onCheckboxClick(e: Event) { | |||
console.log('onCheckboxClick', e); | |||
e.stopPropagation(); | |||
} | |||
onCheckboxChange(e: Event) { | |||
console.log('onCheckboxChange', e); | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
this.checked = (e.target as HTMLInputElement).checked; | |||
this.fireEvent('gl-tree-item-checked', { node: this, checked: this.checked }); | |||
} | |||
} | |||
declare global { | |||
interface HTMLElementTagNameMap { | |||
'gl-tree-item': GlTreeItem; | |||
} | |||
interface WindowEventMap { | |||
'gl-tree-item-select': CustomEvent<undefined>; | |||
'gl-tree-item-selected': CustomEvent<TreeItemSelectionDetail>; | |||
'gl-tree-item-checked': CustomEvent<TreeItemCheckedDetail>; | |||
} | |||
} |
@ -0,0 +1,217 @@ | |||
import { css } from 'lit'; | |||
import { elementBase } from '../styles/lit/base.css'; | |||
export const treeStyles = [elementBase, css``]; | |||
export const treeItemStyles = [ | |||
elementBase, | |||
css` | |||
:host { | |||
--tree-connector-spacing: 0.6rem; | |||
--tree-connector-size: var(--gitlens-tree-indent, 1.6rem); | |||
box-sizing: border-box; | |||
padding-left: var(--gitlens-gutter-width); | |||
padding-right: var(--gitlens-scrollbar-gutter-width); | |||
padding-top: 0.1rem; | |||
padding-bottom: 0.1rem; | |||
line-height: 2.2rem; | |||
height: 2.2rem; | |||
display: flex; | |||
flex-direction: row; | |||
align-items: center; | |||
justify-content: space-between; | |||
font-size: var(--vscode-font-size); | |||
color: var(--vscode-sideBar-foreground); | |||
content-visibility: auto; | |||
contain-intrinsic-size: auto 2.2rem; | |||
cursor: pointer; | |||
} | |||
:host([aria-hidden='true']) { | |||
display: none; | |||
} | |||
:host(:hover) { | |||
color: var(--vscode-list-hoverForeground); | |||
background-color: var(--vscode-list-hoverBackground); | |||
} | |||
:host([aria-selected='true']) { | |||
color: var(--vscode-list-inactiveSelectionForeground); | |||
background-color: var(--vscode-list-inactiveSelectionBackground); | |||
} | |||
/* TODO: these should be :has(.input:focus) instead of :focus-within */ | |||
:host(:focus-within) { | |||
outline: 1px solid var(--vscode-list-focusOutline); | |||
outline-offset: -0.1rem; | |||
} | |||
:host([aria-selected='true']:focus-within) { | |||
color: var(--vscode-list-activeSelectionForeground); | |||
background-color: var(--vscode-list-activeSelectionBackground); | |||
} | |||
.item { | |||
appearance: none; | |||
display: flex; | |||
flex-direction: row; | |||
justify-content: flex-start; | |||
align-items: center; | |||
gap: 0.6rem; | |||
width: 100%; | |||
padding: 0; | |||
text-decoration: none; | |||
color: inherit; | |||
background: none; | |||
border: none; | |||
outline: none; | |||
cursor: pointer; | |||
min-width: 0; | |||
} | |||
/* FIXME: remove, this is for debugging | |||
.item:focus { | |||
outline: 1px solid var(--vscode-list-focusOutline); | |||
outline-offset: -0.1rem; | |||
} | |||
*/ | |||
.icon { | |||
display: inline-block; | |||
width: 1.6rem; | |||
text-align: center; | |||
} | |||
slot[name='icon']::slotted(*) { | |||
width: 1.6rem; | |||
aspect-ratio: 1; | |||
vertical-align: text-bottom; | |||
} | |||
.node { | |||
display: inline-block; | |||
width: var(--tree-connector-size); | |||
text-align: center; | |||
flex: none; | |||
} | |||
.node:last-of-type { | |||
margin-right: 0.3rem; | |||
} | |||
.node--connector { | |||
position: relative; | |||
} | |||
.node--connector::before { | |||
content: ''; | |||
position: absolute; | |||
height: 2.2rem; | |||
border-left: 1px solid transparent; | |||
top: 50%; | |||
transform: translate(-1px, -50%); | |||
left: 0.8rem; | |||
width: 0.1rem; | |||
transition: border-color 0.1s linear; | |||
opacity: 0.4; | |||
} | |||
:host-context([guides='always']) .node--connector::before, | |||
:host-context([guides='onHover']:focus-within) .node--connector::before, | |||
:host-context([guides='onHover']:hover) .node--connector::before { | |||
border-color: var(--vscode-tree-indentGuidesStroke); | |||
} | |||
.branch { | |||
margin-right: 0.6rem; | |||
} | |||
.text { | |||
line-height: 1.6rem; | |||
overflow: hidden; | |||
white-space: nowrap; | |||
text-align: left; | |||
text-overflow: ellipsis; | |||
flex: 1; | |||
} | |||
.main { | |||
display: inline; | |||
} | |||
.description { | |||
display: inline; | |||
opacity: 0.7; | |||
margin-left: 0.3rem; | |||
} | |||
.actions { | |||
flex: none; | |||
user-select: none; | |||
color: var(--vscode-icon-foreground); | |||
} | |||
:host(:focus-within) .actions { | |||
color: var(--vscode-list-activeSelectionIconForeground); | |||
} | |||
:host(:not(:hover):not(:focus-within)) .actions { | |||
display: none; | |||
} | |||
.checkbox { | |||
position: relative; | |||
display: inline-flex; | |||
width: 1.6rem; | |||
aspect-ratio: 1 / 1; | |||
text-align: center; | |||
color: var(--vscode-checkbox-foreground); | |||
background: var(--vscode-checkbox-background); | |||
border: 1px solid var(--vscode-checkbox-border); | |||
border-radius: 0.3rem; | |||
// overflow: hidden; | |||
margin-right: 0.6rem; | |||
} | |||
.checkbox:has(:checked) { | |||
color: var(--vscode-inputOption-activeForeground); | |||
border-color: var(--vscode-inputOption-activeBorder); | |||
background-color: var(--vscode-inputOption-activeBackground); | |||
} | |||
.checkbox:has(:disabled) { | |||
opacity: 0.4; | |||
} | |||
.checkbox__input { | |||
position: absolute; | |||
top: 0; | |||
left: 0; | |||
appearance: none; | |||
width: 1.4rem; | |||
aspect-ratio: 1 / 1; | |||
margin: 0; | |||
cursor: pointer; | |||
border-radius: 0.3rem; | |||
} | |||
.checkbox__input:disabled { | |||
cursor: default; | |||
} | |||
.checkbox__check { | |||
width: 1.6rem; | |||
aspect-ratio: 1 / 1; | |||
opacity: 0; | |||
transition: opacity 0.1s linear; | |||
color: var(--vscode-checkbox-foreground); | |||
pointer-events: none; | |||
} | |||
.checkbox__input:checked + .checkbox__check { | |||
opacity: 1; | |||
} | |||
`, | |||
]; |
@ -0,0 +1,120 @@ | |||
import { html, LitElement } from 'lit'; | |||
import { customElement, property, queryAssignedElements } from 'lit/decorators.js'; | |||
import type { TreeItemSelectionDetail } from './base'; | |||
import type { GlTreeItem } from './tree-item'; | |||
import { treeStyles } from './tree.css'; | |||
@customElement('gl-tree') | |||
export class GlTree extends LitElement { | |||
static override styles = treeStyles; | |||
@property({ reflect: true }) | |||
guides?: 'none' | 'onHover' | 'always'; | |||
private _slotSubscriptionsDisposer?: () => void; | |||
private _lastSelected?: GlTreeItem; | |||
@queryAssignedElements({ flatten: true }) | |||
private treeItems!: GlTreeItem[]; | |||
override disconnectedCallback() { | |||
super.disconnectedCallback(); | |||
this._slotSubscriptionsDisposer?.(); | |||
} | |||
override firstUpdated() { | |||
this.setAttribute('role', 'tree'); | |||
} | |||
override render() { | |||
return html`<slot @slotchange=${this.handleSlotChange}></slot>`; | |||
} | |||
private handleSlotChange() { | |||
console.log('handleSlotChange'); | |||
if (!this.treeItems?.length) return; | |||
const keyHandler = this.handleKeydown.bind(this); | |||
const beforeSelectHandler = this.handleBeforeSelected.bind(this) as EventListenerOrEventListenerObject; | |||
const selectHandler = this.handleSelected.bind(this) as EventListenerOrEventListenerObject; | |||
const subscriptions = this.treeItems.map(node => { | |||
node.addEventListener('keydown', keyHandler, false); | |||
node.addEventListener('gl-tree-item-select', beforeSelectHandler, false); | |||
node.addEventListener('gl-tree-item-selected', selectHandler, false); | |||
return { | |||
dispose: function () { | |||
node?.removeEventListener('keydown', keyHandler, false); | |||
node?.removeEventListener('gl-tree-item-select', beforeSelectHandler, false); | |||
node?.removeEventListener('gl-tree-item-selected', selectHandler, false); | |||
}, | |||
}; | |||
}); | |||
this._slotSubscriptionsDisposer = () => { | |||
subscriptions?.forEach(({ dispose }) => dispose()); | |||
}; | |||
} | |||
private handleKeydown(e: KeyboardEvent) { | |||
if (!e.target) return; | |||
const target = e.target as HTMLElement; | |||
if (e.key === 'ArrowUp') { | |||
const $previous = target.previousElementSibling as HTMLElement | null; | |||
$previous?.focus(); | |||
} else if (e.key === 'ArrowDown') { | |||
const $next = target.nextElementSibling as HTMLElement | null; | |||
$next?.focus(); | |||
} | |||
} | |||
private handleBeforeSelected(e: CustomEvent) { | |||
if (!e.target) return; | |||
const target = e.target as GlTreeItem; | |||
if (this._lastSelected != null && this._lastSelected !== target) { | |||
this._lastSelected.deselect(); | |||
} | |||
this._lastSelected = target; | |||
} | |||
private handleSelected(e: CustomEvent<TreeItemSelectionDetail>) { | |||
if (!e.target || !e.detail.node.branch) return; | |||
function getParent(el: GlTreeItem) { | |||
const currentLevel = el.level; | |||
let prev = el.previousElementSibling as GlTreeItem | null; | |||
while (prev) { | |||
const prevLevel = prev.level; | |||
if (prevLevel < currentLevel) return prev; | |||
prev = prev.previousElementSibling as GlTreeItem | null; | |||
} | |||
return undefined; | |||
} | |||
const target = e.target as GlTreeItem; | |||
const level = target.level; | |||
let nextElement = target.nextElementSibling as GlTreeItem | null; | |||
while (nextElement) { | |||
if (level === nextElement.level) break; | |||
const parentElement = getParent(nextElement); | |||
nextElement.parentExpanded = parentElement?.expanded !== false; | |||
nextElement.expanded = e.detail.node.expanded; | |||
nextElement = nextElement.nextElementSibling as GlTreeItem; | |||
} | |||
} | |||
} | |||
declare global { | |||
interface HTMLElementTagNameMap { | |||
'gl-tree': GlTree; | |||
} | |||
} |
@ -0,0 +1,445 @@ | |||
@use './theme'; | |||
:root { | |||
--gitlens-gutter-width: 20px; | |||
--gitlens-scrollbar-gutter-width: 10px; | |||
} | |||
.vscode-high-contrast, | |||
.vscode-dark { | |||
--color-background--level-05: var(--color-background--lighten-05); | |||
--color-background--level-075: var(--color-background--lighten-075); | |||
--color-background--level-10: var(--color-background--lighten-10); | |||
--color-background--level-15: var(--color-background--lighten-15); | |||
--color-background--level-30: var(--color-background--lighten-30); | |||
} | |||
.vscode-high-contrast-light, | |||
.vscode-light { | |||
--color-background--level-05: var(--color-background--darken-05); | |||
--color-background--level-075: var(--color-background--darken-075); | |||
--color-background--level-10: var(--color-background--darken-10); | |||
--color-background--level-15: var(--color-background--darken-15); | |||
--color-background--level-30: var(--color-background--darken-30); | |||
} | |||
// generic resets | |||
html { | |||
font-size: 62.5%; | |||
// box-sizing: border-box; | |||
font-family: var(--font-family); | |||
} | |||
*, | |||
*:before, | |||
*:after { | |||
box-sizing: border-box; | |||
} | |||
body { | |||
--gk-badge-outline-color: var(--vscode-badge-foreground); | |||
--gk-badge-filled-background-color: var(--vscode-badge-background); | |||
--gk-badge-filled-color: var(--vscode-badge-foreground); | |||
font-family: var(--font-family); | |||
font-size: var(--font-size); | |||
color: var(--color-foreground); | |||
padding: 0; | |||
&.scrollable, | |||
.scrollable { | |||
border-color: transparent; | |||
transition: border-color 1s linear; | |||
&:hover, | |||
&:focus-within { | |||
&.scrollable, | |||
.scrollable { | |||
border-color: var(--vscode-scrollbarSlider-background); | |||
transition: none; | |||
} | |||
} | |||
} | |||
&.preload { | |||
&.scrollable, | |||
.scrollable { | |||
transition: none; | |||
} | |||
} | |||
} | |||
::-webkit-scrollbar-corner { | |||
background-color: transparent !important; | |||
} | |||
::-webkit-scrollbar-thumb { | |||
background-color: transparent; | |||
border-color: inherit; | |||
border-right-style: inset; | |||
border-right-width: calc(100vw + 100vh); | |||
border-radius: unset !important; | |||
&:hover { | |||
border-color: var(--vscode-scrollbarSlider-hoverBackground); | |||
} | |||
&:active { | |||
border-color: var(--vscode-scrollbarSlider-activeBackground); | |||
} | |||
} | |||
a { | |||
text-decoration: none; | |||
&:hover { | |||
text-decoration: underline; | |||
} | |||
} | |||
ul { | |||
list-style: none; | |||
margin: 0; | |||
padding: 0; | |||
} | |||
.bulleted { | |||
list-style: disc; | |||
padding-left: 1.2em; | |||
> li + li { | |||
margin-top: 0.25em; | |||
} | |||
} | |||
.button { | |||
--button-foreground: var(--vscode-button-foreground); | |||
--button-background: var(--vscode-button-background); | |||
--button-hover-background: var(--vscode-button-hoverBackground); | |||
display: inline-block; | |||
border: none; | |||
padding: 0.4rem; | |||
font-family: inherit; | |||
font-size: inherit; | |||
line-height: 1.4; | |||
text-align: center; | |||
text-decoration: none; | |||
user-select: none; | |||
background: var(--button-background); | |||
color: var(--button-foreground); | |||
cursor: pointer; | |||
&:hover { | |||
background: var(--button-hover-background); | |||
} | |||
&:focus { | |||
outline: 1px solid var(--vscode-focusBorder); | |||
outline-offset: 0.2rem; | |||
} | |||
&--full { | |||
width: 100%; | |||
} | |||
code-icon { | |||
pointer-events: none; | |||
} | |||
} | |||
.button--busy { | |||
code-icon { | |||
margin-right: 0.5rem; | |||
} | |||
&[aria-busy='true'] { | |||
opacity: 0.5; | |||
} | |||
&:not([aria-busy='true']) { | |||
code-icon { | |||
display: none; | |||
} | |||
} | |||
} | |||
.button-container { | |||
margin: 1rem auto 0; | |||
text-align: left; | |||
max-width: 30rem; | |||
transition: max-width 0.2s ease-out; | |||
} | |||
@media (min-width: 640px) { | |||
.button-container { | |||
max-width: 100%; | |||
} | |||
} | |||
.button-group { | |||
display: inline-flex; | |||
gap: 0.1rem; | |||
&--single { | |||
width: 100%; | |||
max-width: 30rem; | |||
} | |||
} | |||
.section { | |||
padding: 0 var(--gitlens-scrollbar-gutter-width) 1.5rem var(--gitlens-gutter-width); | |||
> :first-child { | |||
margin-top: 0; | |||
} | |||
> :last-child { | |||
margin-bottom: 0; | |||
} | |||
} | |||
.section--message { | |||
padding: { | |||
top: 1rem; | |||
bottom: 1.75rem; | |||
} | |||
} | |||
.section--empty { | |||
> :last-child { | |||
margin-top: 0.5rem; | |||
} | |||
} | |||
.section--skeleton { | |||
padding: { | |||
top: 1px; | |||
bottom: 1px; | |||
} | |||
} | |||
.commit-action { | |||
display: inline-flex; | |||
justify-content: center; | |||
align-items: center; | |||
height: 21px; | |||
border-radius: 0.25em; | |||
color: inherit; | |||
padding: 0.2rem; | |||
vertical-align: text-bottom; | |||
text-decoration: none; | |||
> * { | |||
pointer-events: none; | |||
} | |||
&:focus { | |||
outline: 1px solid var(--vscode-focusBorder); | |||
outline-offset: -1px; | |||
} | |||
&:hover { | |||
color: var(--vscode-foreground); | |||
text-decoration: none; | |||
.vscode-dark & { | |||
background-color: var(--color-background--lighten-15); | |||
} | |||
.vscode-light & { | |||
background-color: var(--color-background--darken-15); | |||
} | |||
} | |||
&.is-active { | |||
.vscode-dark & { | |||
background-color: var(--color-background--lighten-10); | |||
} | |||
.vscode-light & { | |||
background-color: var(--color-background--darken-10); | |||
} | |||
} | |||
&.is-disabled { | |||
opacity: 0.5; | |||
pointer-events: none; | |||
} | |||
&.is-hidden { | |||
display: none; | |||
} | |||
&--emphasis-low:not(:hover, :focus, :active) { | |||
opacity: 0.5; | |||
} | |||
} | |||
.change-list { | |||
margin-bottom: 1rem; | |||
} | |||
.message-block { | |||
font-size: 1.3rem; | |||
border: 1px solid var(--vscode-input-border); | |||
background: var(--vscode-input-background); | |||
padding: 0.5rem; | |||
&__text { | |||
margin: 0; | |||
overflow-y: auto; | |||
overflow-x: hidden; | |||
max-height: 9rem; | |||
> * { | |||
white-space: break-spaces; | |||
} | |||
strong { | |||
font-weight: 600; | |||
font-size: 1.4rem; | |||
} | |||
} | |||
} | |||
.top-details { | |||
position: sticky; | |||
top: 0; | |||
z-index: 1; | |||
padding: { | |||
top: 0.1rem; | |||
left: var(--gitlens-gutter-width); | |||
right: var(--gitlens-scrollbar-gutter-width); | |||
bottom: 0.5rem; | |||
} | |||
background-color: var(--vscode-sideBar-background); | |||
&__actionbar { | |||
display: flex; | |||
flex-direction: row; | |||
flex-wrap: wrap; | |||
align-items: center; | |||
justify-content: space-between; | |||
&-group { | |||
display: flex; | |||
flex: none; | |||
} | |||
&--highlight { | |||
margin-left: 0.25em; | |||
padding: 0 4px 2px 4px; | |||
border: 1px solid var(--color-background--level-15); | |||
border-radius: 0.3rem; | |||
font-family: var(--vscode-editor-font-family); | |||
} | |||
&.is-pinned { | |||
background-color: var(--color-alert-warningBackground); | |||
box-shadow: 0 0 0 0.1rem var(--color-alert-warningBorder); | |||
border-radius: 0.3rem; | |||
.commit-action:hover, | |||
.commit-action.is-active { | |||
background-color: var(--color-alert-warningHoverBackground); | |||
} | |||
} | |||
} | |||
&__sha { | |||
margin: 0 0.5rem 0 0.25rem; | |||
} | |||
&__authors { | |||
flex-basis: 100%; | |||
padding-top: 0.5rem; | |||
} | |||
&__author { | |||
& + & { | |||
margin-top: 0.5rem; | |||
} | |||
} | |||
} | |||
.issue > :not(:first-child) { | |||
margin-top: 0.5rem; | |||
} | |||
.commit-detail-panel { | |||
max-height: 100vh; | |||
overflow: auto; | |||
scrollbar-gutter: stable; | |||
color: var(--vscode-sideBar-foreground); | |||
background-color: var(--vscode-sideBar-background); | |||
[aria-hidden='true'] { | |||
display: none; | |||
} | |||
} | |||
.ai-content { | |||
font-size: 1.3rem; | |||
border: 0.1rem solid var(--vscode-input-border, transparent); | |||
background: var(--vscode-input-background); | |||
margin-top: 1rem; | |||
padding: 0.5rem; | |||
&.has-error { | |||
border-left-color: var(--color-alert-errorBorder); | |||
border-left-width: 0.3rem; | |||
padding-left: 0.8rem; | |||
} | |||
&:empty { | |||
display: none; | |||
} | |||
&__summary { | |||
margin: 0; | |||
overflow-y: auto; | |||
overflow-x: hidden; | |||
resize: vertical; | |||
max-height: 20rem; | |||
white-space: break-spaces; | |||
.has-error & { | |||
white-space: normal; | |||
} | |||
} | |||
} | |||
.details-tab { | |||
display: flex; | |||
justify-content: stretch; | |||
align-items: center; | |||
margin-bottom: 0.4rem; | |||
gap: 0.2rem; | |||
& > * { | |||
flex: 1; | |||
} | |||
&__item { | |||
appearance: none; | |||
padding: 0.4rem; | |||
color: var(--color-foreground--85); | |||
background-color: transparent; | |||
border: none; | |||
border-bottom: 0.2rem solid transparent; | |||
cursor: pointer; | |||
// background-color: #00000030; | |||
line-height: 1.8rem; | |||
gk-badge { | |||
line-height: 1.2; | |||
} | |||
&:hover { | |||
color: var(--color-foreground); | |||
// background-color: var(--vscode-button-hoverBackground); | |||
background-color: #00000020; | |||
} | |||
&.is-active { | |||
color: var(--color-foreground); | |||
border-bottom-color: var(--vscode-button-hoverBackground); | |||
} | |||
} | |||
} |