diff --git a/contributions.json b/contributions.json index 0cf1fed187f79..ae7516aca301b 100644 --- a/contributions.json +++ b/contributions.json @@ -23,6 +23,32 @@ ] } }, + "gitlens.ai.explainCommit": { + "label": "Explain Commit...", + "commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "menus": { + "view/item/context": [ + { + "when": "viewItem =~ /gitlens:commit\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "3_gitlens_ai", + "order": 1 + } + ] + } + }, + "gitlens.ai.explainStash": { + "label": "Explain Stash (Preview)...", + "commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "menus": { + "view/item/context": [ + { + "when": "viewItem =~ /gitlens:stash\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "3_gitlens_ai", + "order": 1 + } + ] + } + }, "gitlens.ai.generateChangelog": { "label": "Generate Changelog (Preview)...", "commandPalette": "gitlens:enabled && !gitlens:untrusted && gitlens:gk:organization:ai:enabled" @@ -1962,6 +1988,30 @@ ] } }, + "gitlens.graph.explainCommit": { + "label": "Explain Commit", + "menus": { + "webview/context": [ + { + "when": "webviewItem =~ /gitlens:commit\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "1_gitlens_actions_3", + "order": 1 + } + ] + } + }, + "gitlens.graph.explainStash": { + "label": "Explain Stash (Preview)", + "menus": { + "webview/context": [ + { + "when": "webviewItem =~ /gitlens:stash\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "1_gitlens_actions_3", + "order": 1 + } + ] + } + }, "gitlens.graph.fetch": { "label": "Fetch", "icon": "$(repo-fetch)", diff --git a/package.json b/package.json index 8d9e6a5ecf98b..e91da908ca905 100644 --- a/package.json +++ b/package.json @@ -6054,6 +6054,16 @@ "category": "GitLens", "icon": "$(person-add)" }, + { + "command": "gitlens.ai.explainCommit", + "title": "Explain Commit...", + "category": "GitLens" + }, + { + "command": "gitlens.ai.explainStash", + "title": "Explain Stash (Preview)...", + "category": "GitLens" + }, { "command": "gitlens.ai.generateChangelog", "title": "Generate Changelog (Preview)...", @@ -6817,6 +6827,14 @@ "icon": "$(trash)", "enablement": "!operationInProgress" }, + { + "command": "gitlens.graph.explainCommit", + "title": "Explain Commit" + }, + { + "command": "gitlens.graph.explainStash", + "title": "Explain Stash (Preview)" + }, { "command": "gitlens.graph.fetch", "title": "Fetch", @@ -10362,6 +10380,14 @@ "command": "gitlens.addAuthors", "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, + { + "command": "gitlens.ai.explainCommit", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled" + }, + { + "command": "gitlens.ai.explainStash", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled" + }, { "command": "gitlens.ai.generateChangelog", "when": "gitlens:enabled && !gitlens:untrusted && gitlens:gk:organization:ai:enabled" @@ -10954,6 +10980,14 @@ "command": "gitlens.graph.deleteTag", "when": "false" }, + { + "command": "gitlens.graph.explainCommit", + "when": "false" + }, + { + "command": "gitlens.graph.explainStash", + "when": "false" + }, { "command": "gitlens.graph.fetch", "when": "false" @@ -16578,6 +16612,11 @@ "when": "viewItem =~ /gitlens:(compare:results(?!:)\\b(?!.*?\\b\\+filtered\\b)|commit|stash|results:files|status-branch:files|status:upstream:(ahead|behind))\\b/ && !listMultiSelection", "group": "2_gitlens_quickopen@1" }, + { + "command": "gitlens.ai.explainCommit", + "when": "viewItem =~ /gitlens:commit\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "3_gitlens_ai@1" + }, { "command": "gitlens.showInDetailsView", "when": "viewItem =~ /gitlens:(commit|stash)\\b/ && !listMultiSelection", @@ -17634,6 +17673,11 @@ "when": "viewItem == gitlens:stash && listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", "group": "1_gitlens_actions@3" }, + { + "command": "gitlens.ai.explainStash", + "when": "viewItem =~ /gitlens:stash\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "3_gitlens_ai@1" + }, { "command": "gitlens.stashSave", "when": "viewItem =~ /^gitlens:(stashes|status:files)$/ && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", @@ -20099,6 +20143,11 @@ "when": "webviewItem =~ /gitlens:commit\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", "group": "1_gitlens_actions_1@4" }, + { + "command": "gitlens.graph.explainCommit", + "when": "webviewItem =~ /gitlens:commit\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "1_gitlens_actions_3@1" + }, { "submenu": "gitlens/graph/commit/changes", "when": "webviewItem =~ /gitlens:(commit|stash|wip)\\b/ && !listMultiSelection", @@ -20259,6 +20308,11 @@ "when": "webviewItem == gitlens:stash && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", "group": "1_gitlens_actions@3" }, + { + "command": "gitlens.graph.explainStash", + "when": "webviewItem =~ /gitlens:stash\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "1_gitlens_actions_3@1" + }, { "command": "gitlens.graph.switchToTag", "when": "webviewItem =~ /gitlens:tag\\b/ && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", diff --git a/src/commands.ts b/src/commands.ts index 733f1bd2a2698..56a1b4f6d2f50 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -21,6 +21,8 @@ import './commands/diffWithRevision'; import './commands/diffWithRevisionFrom'; import './commands/diffWithWorking'; import './commands/externalDiff'; +import './commands/explainCommit'; +import './commands/explainStash'; import './commands/generateChangelog'; import './commands/generateCommitMessage'; import './commands/ghpr/openOrCreateWorktree'; diff --git a/src/commands/explainCommit.ts b/src/commands/explainCommit.ts new file mode 100644 index 0000000000000..b4f04b160fa1f --- /dev/null +++ b/src/commands/explainCommit.ts @@ -0,0 +1,102 @@ +import type { TextEditor, Uri } from 'vscode'; +import { ProgressLocation } from 'vscode'; +import type { Container } from '../container'; +import { GitUri } from '../git/gitUri'; +import { showGenericErrorMessage } from '../messages'; +import type { AIExplainSource } from '../plus/ai/aiProviderService'; +import { showCommitPicker } from '../quickpicks/commitPicker'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { command } from '../system/-webview/command'; +import { showMarkdownPreview } from '../system/-webview/markdown'; +import { Logger } from '../system/logger'; +import { getNodeRepoPath } from '../views/nodes/abstract/viewNode'; +import { GlCommandBase } from './commandBase'; +import { getCommandUri } from './commandBase.utils'; +import type { CommandContext } from './commandContext'; +import { isCommandContextViewNodeHasCommit } from './commandContext.utils'; + +export interface ExplainCommitCommandArgs { + repoPath?: string | Uri; + ref?: string; + source?: AIExplainSource; +} + +@command() +export class ExplainCommitCommand extends GlCommandBase { + constructor(private readonly container: Container) { + super('gitlens.ai.explainCommit'); + } + + protected override preExecute(context: CommandContext, args?: ExplainCommitCommandArgs): Promise { + // Check if the command is being called from a CommitNode + if (isCommandContextViewNodeHasCommit(context)) { + args = { ...args }; + args.repoPath = args.repoPath ?? getNodeRepoPath(context.node); + args.ref = args.ref ?? context.node.commit.sha; + args.source = args.source ?? { source: 'view', type: 'commit' }; + } + + return this.execute(context.editor, context.uri, args); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: ExplainCommitCommandArgs): Promise { + args = { ...args }; + + let repository; + if (args?.repoPath != null) { + repository = this.container.git.getRepository(args.repoPath); + } + + if (repository == null) { + uri = getCommandUri(uri, editor); + const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; + repository = await getBestRepositoryOrShowPicker( + gitUri, + editor, + 'Explain Commit', + 'Choose which repository to explain a commit from', + ); + } + + if (repository == null) return; + + try { + // If no ref is provided, show a picker to select a commit + if (args.ref == null) { + const commitsProvider = repository.git.commits(); + const log = await commitsProvider.getLog(); + const pick = await showCommitPicker(log, 'Explain Commit', 'Choose a commit to explain'); + if (pick?.sha == null) return; + args.ref = pick.sha; + } + + // Get the commit + const commit = await repository.git.commits().getCommit(args.ref); + if (commit == null) { + void showGenericErrorMessage('Unable to find the specified commit'); + return; + } + + // Call the AI service to explain the commit + const result = await this.container.ai.explainCommit( + commit, + args.source ?? { source: 'commandPalette', type: 'commit' }, + { + progress: { location: ProgressLocation.Notification, title: 'Explaining commit...' }, + }, + ); + + // Display the result + let content = `# Commit Summary\n\n`; + if (result != null) { + content += `> Generated by ${result.model.name}\n\n## ${commit.summary} (${commit.shortSha})\n\n${result?.parsed.summary}\n\n${result?.parsed.body}`; + } else { + content += `> No changes found to explain.`; + } + void showMarkdownPreview(content); + } catch (ex) { + Logger.error(ex, 'ExplainCommitCommand', 'execute'); + void showGenericErrorMessage('Unable to explain commit'); + } + } +} diff --git a/src/commands/explainStash.ts b/src/commands/explainStash.ts new file mode 100644 index 0000000000000..770df1e0bb13d --- /dev/null +++ b/src/commands/explainStash.ts @@ -0,0 +1,101 @@ +import type { TextEditor, Uri } from 'vscode'; +import { ProgressLocation } from 'vscode'; +import type { Container } from '../container'; +import { GitUri } from '../git/gitUri'; +import type { GitStashCommit } from '../git/models/commit'; +import { showGenericErrorMessage } from '../messages'; +import type { AIExplainSource } from '../plus/ai/aiProviderService'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { showStashPicker } from '../quickpicks/stashPicker'; +import { command } from '../system/-webview/command'; +import { showMarkdownPreview } from '../system/-webview/markdown'; +import { Logger } from '../system/logger'; +import { GlCommandBase } from './commandBase'; +import { getCommandUri } from './commandBase.utils'; +import type { CommandContext } from './commandContext'; +import { isCommandContextViewNodeHasCommit } from './commandContext.utils'; + +export interface ExplainStashCommandArgs { + repoPath?: string | Uri; + ref?: string; + source?: AIExplainSource; +} + +@command() +export class ExplainStashCommand extends GlCommandBase { + constructor(private readonly container: Container) { + super('gitlens.ai.explainStash'); + } + + protected override preExecute(context: CommandContext, args?: ExplainStashCommandArgs): Promise { + // Check if the command is being called from a CommitNode + if (isCommandContextViewNodeHasCommit(context)) { + args = { ...args }; + args.repoPath = args.repoPath ?? context.node.commit.repoPath; + args.ref = args.ref ?? context.node.commit.sha; + args.source = args.source ?? { source: 'view', type: 'stash' }; + } + + return this.execute(context.editor, context.uri, args); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: ExplainStashCommandArgs): Promise { + args = { ...args }; + + let repository; + if (args?.repoPath != null) { + repository = this.container.git.getRepository(args.repoPath); + } + + if (repository == null) { + uri = getCommandUri(uri, editor); + const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; + repository = await getBestRepositoryOrShowPicker( + gitUri, + editor, + 'Explain Stash', + 'Choose which repository to explain a stash from', + ); + } + + if (repository == null) return; + + try { + // If no ref is provided, show a picker to select a stash + if (args.ref == null) { + const pick = await showStashPicker('Explain Stash', 'Choose a stash to explain', repository); + if (pick?.ref == null) return; + args.ref = pick.ref; + } + + // Get the stash commit + const commit = await repository.git.commits().getCommit(args.ref); + if (commit == null) { + void showGenericErrorMessage('Unable to find the specified stash commit'); + return; + } + + // Call the AI service to explain the stash + const result = await this.container.ai.explainCommit( + commit, + args.source ?? { source: 'commandPalette', type: 'stash' }, + { + progress: { location: ProgressLocation.Notification, title: 'Explaining stash...' }, + }, + ); + + // Display the result + let content = `# Stash Summary\n\n`; + if (result != null) { + content += `> Generated by ${result.model.name}\n\n## ${commit.message || commit.ref}}\n\n${result + ?.parsed.summary}\n\n${result?.parsed.body}`; + } else { + content += `> No changes found to explain.`; + } + void showMarkdownPreview(content); + } catch (ex) { + Logger.error(ex, 'ExplainStashCommand', 'execute'); + void showGenericErrorMessage('Unable to explain stash'); + } + } +} diff --git a/src/commands/quickCommand.steps.ts b/src/commands/quickCommand.steps.ts index 358d090a5b083..5d626f9167c0a 100644 --- a/src/commands/quickCommand.steps.ts +++ b/src/commands/quickCommand.steps.ts @@ -52,6 +52,7 @@ import { CommitCompareWithWorkingCommandQuickPickItem, CommitCopyIdQuickPickItem, CommitCopyMessageQuickPickItem, + CommitExplainCommandQuickPickItem, CommitFileQuickPickItem, CommitFilesQuickPickItem, CommitOpenAllChangesCommandQuickPickItem, @@ -2149,6 +2150,8 @@ async function getShowCommitOrStashStepItems< new CommitCopyMessageQuickPickItem(state.reference), ); } else { + items.push(createQuickPickSeparator(), new CommitExplainCommandQuickPickItem(state.reference)); + const remotes = await state.repo.git.remotes().getRemotesWithProviders({ sort: true }); if (remotes?.length) { items.push( @@ -2487,6 +2490,8 @@ async function getShowCommitOrStashFileStepItems< new CommitCopyMessageQuickPickItem(state.reference), ); } else { + items.push(createQuickPickSeparator(), new CommitExplainCommandQuickPickItem(state.reference)); + const remotes = await state.repo.git.remotes().getRemotesWithProviders({ sort: true }); if (remotes?.length) { items.push( diff --git a/src/constants.commands.generated.ts b/src/constants.commands.generated.ts index 8db43adeec502..3f852a1edec7f 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -71,6 +71,8 @@ export type ContributedCommands = | 'gitlens.graph.createWorktree' | 'gitlens.graph.deleteBranch' | 'gitlens.graph.deleteTag' + | 'gitlens.graph.explainCommit' + | 'gitlens.graph.explainStash' | 'gitlens.graph.fetch' | 'gitlens.graph.hideLocalBranch' | 'gitlens.graph.hideRefGroup' @@ -611,6 +613,8 @@ export type ContributedCommands = export type ContributedPaletteCommands = | 'gitlens.addAuthors' + | 'gitlens.ai.explainCommit' + | 'gitlens.ai.explainStash' | 'gitlens.ai.generateChangelog' | 'gitlens.ai.generateCommitMessage' | 'gitlens.applyPatchFromClipboard' diff --git a/src/git/actions/commit.ts b/src/git/actions/commit.ts index 508166053d6f5..22b01702d84cb 100644 --- a/src/git/actions/commit.ts +++ b/src/git/actions/commit.ts @@ -3,6 +3,7 @@ import { env, Range, Uri, window, workspace } from 'vscode'; import type { DiffWithCommandArgs } from '../../commands/diffWith'; import type { DiffWithPreviousCommandArgs } from '../../commands/diffWithPrevious'; import type { DiffWithWorkingCommandArgs } from '../../commands/diffWithWorking'; +import type { ExplainCommitCommandArgs } from '../../commands/explainCommit'; import type { OpenFileOnRemoteCommandArgs } from '../../commands/openFileOnRemote'; import type { OpenOnlyChangedFilesCommandArgs } from '../../commands/openOnlyChangedFiles'; import type { OpenWorkingFileCommandArgs } from '../../commands/openWorkingFile'; @@ -11,6 +12,7 @@ import type { ShowQuickCommitFileCommandArgs } from '../../commands/showQuickCom import type { FileAnnotationType } from '../../config'; import { GlyphChars } from '../../constants'; import { Container } from '../../container'; +import type { AIExplainSource } from '../../plus/ai/aiProviderService'; import { showRevisionFilesPicker } from '../../quickpicks/revisionFilesPicker'; import { executeCommand, executeCoreGitCommand, executeEditorCommand } from '../../system/-webview/command'; import { configuration } from '../../system/-webview/configuration'; @@ -751,6 +753,17 @@ export async function showInCommitGraph( })); } +export async function explainCommit( + commit: GitRevisionReference | GitCommit, + options?: { source?: AIExplainSource }, +): Promise { + void (await executeCommand('gitlens.ai.explainCommit', { + repoPath: commit.repoPath, + ref: commit.ref, + source: options?.source, + })); +} + export async function openOnlyChangedFiles(container: Container, commit: GitCommit): Promise; export async function openOnlyChangedFiles(container: Container, files: GitFile[]): Promise; export async function openOnlyChangedFiles(container: Container, commitOrFiles: GitCommit | GitFile[]): Promise { diff --git a/src/plus/ai/aiProviderService.ts b/src/plus/ai/aiProviderService.ts index 107d0b8e63836..c105267997443 100644 --- a/src/plus/ai/aiProviderService.ts +++ b/src/plus/ai/aiProviderService.ts @@ -108,6 +108,8 @@ export interface AIModelChangeEvent { readonly model: AIModel | undefined; } +export type AIExplainSource = Source & { type: TelemetryEvents['ai/explain']['changeType'] }; + // Order matters for sorting the picker const supportedAIProviders = new Map([ [ @@ -490,14 +492,11 @@ export class AIProviderService implements Disposable { async explainCommit( commitOrRevision: GitRevisionReference | GitCommit, - sourceContext: Source & { type: TelemetryEvents['ai/explain']['changeType'] }, + sourceContext: AIExplainSource, options?: { cancellation?: CancellationToken; progress?: ProgressOptions }, ): Promise { - const { type, ...source } = sourceContext; - - const result = await this.sendRequest( - 'explain-changes', - async (model, reporting, cancellation, maxInputTokens, retries) => { + return this.explainChanges( + async cancellation => { const diff = await this.container.git.diff(commitOrRevision.repoPath).getDiff?.(commitOrRevision.ref); if (!diff?.contents) throw new AINoRequestDataError('No changes found to explain.'); if (cancellation.isCancellationRequested) throw new CancellationError(); @@ -511,18 +510,45 @@ export class AIProviderService implements Disposable { if (!commit.hasFullDetails()) { await commit.ensureFullDetails(); assertsCommitHasFullDetails(commit); - if (cancellation.isCancellationRequested) throw new CancellationError(); } + return { + diff: diff.contents, + message: commit.message, + }; + }, + sourceContext, + options, + ); + } + + async explainChanges( + promptContext: + | PromptTemplateContext<'explain-changes'> + | ((cancellationToken: CancellationToken) => Promise>), + sourceContext: AIExplainSource, + options?: { cancellation?: CancellationToken; progress?: ProgressOptions }, + ): Promise { + const { type, ...source } = sourceContext; + + const result = await this.sendRequest( + 'explain-changes', + async (model, reporting, cancellation, maxInputTokens, retries) => { + if (typeof promptContext === 'function') { + promptContext = await promptContext(cancellation); + } + + promptContext.instructions = `${ + promptContext.instructions ? `${promptContext.instructions}\n` : '' + }${configuration.get('ai.explainChanges.customInstructions')}`; + + if (cancellation.isCancellationRequested) throw new CancellationError(); + const { prompt } = await this.getPrompt( 'explain-changes', model, - { - diff: diff.contents, - message: commit.message, - instructions: configuration.get('ai.explainChanges.customInstructions'), - }, + promptContext, maxInputTokens, retries, reporting, diff --git a/src/quickpicks/items/commits.ts b/src/quickpicks/items/commits.ts index 5b08b90383d37..41d40e6acd4b9 100644 --- a/src/quickpicks/items/commits.ts +++ b/src/quickpicks/items/commits.ts @@ -290,6 +290,21 @@ export class CommitOpenInGraphCommandQuickPickItem extends CommandQuickPickItem } } +export class CommitExplainCommandQuickPickItem extends CommandQuickPickItem { + constructor(private readonly commit: GitCommit) { + super('Explain Changes', new ThemeIcon('sparkle')); + } + + override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { + return CommitActions.explainCommit(this.commit, { + source: { + source: 'commandPalette', + type: 'commit', + }, + }); + } +} + export class CommitOpenFilesCommandQuickPickItem extends CommandQuickPickItem { constructor(private readonly commit: GitCommit) { super('Open Files', new ThemeIcon('files')); diff --git a/src/quickpicks/stashPicker.ts b/src/quickpicks/stashPicker.ts new file mode 100644 index 0000000000000..820f701423d4a --- /dev/null +++ b/src/quickpicks/stashPicker.ts @@ -0,0 +1,93 @@ +import type { Disposable } from 'vscode'; +import { window } from 'vscode'; +import { RevealInSideBarQuickInputButton, ShowDetailsViewQuickInputButton } from '../commands/quickCommand.buttons'; +import * as StashActions from '../git/actions/stash'; +import type { GitStashCommit } from '../git/models/commit'; +import { Repository } from '../git/models/repository'; +import { getQuickPickIgnoreFocusOut } from '../system/-webview/vscode'; +import type { CommitQuickPickItem } from './items/gitWizard'; +import { createStashQuickPickItem } from './items/gitWizard'; + +export async function showStashPicker( + title: string | undefined, + placeholder?: string, + repository?: Repository | Repository[], + options?: { + filter?: (b: GitStashCommit) => boolean; + }, +): Promise { + if (repository == null) { + return undefined; + } + + if (repository instanceof Repository) { + repository = [repository]; + } + + let stashes: GitStashCommit[] = []; + for (const repo of repository) { + const stash = await repo.git.stash()?.getStash(); + if (stash == null || stash.stashes.size === 0) { + continue; + } + + stashes.push(...stash.stashes.values()); + } + + if (options?.filter != null) { + stashes = stashes.filter(options.filter); + } + + if (stashes.length === 0) { + return undefined; + } + + const items: CommitQuickPickItem[] = stashes.map(stash => + createStashQuickPickItem(stash, false, { + buttons: [ShowDetailsViewQuickInputButton, RevealInSideBarQuickInputButton], + compact: true, + icon: true, + }), + ); + + const quickpick = window.createQuickPick>(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + const disposables: Disposable[] = []; + + try { + const pick = await new Promise | undefined>(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpick.activeItems[0]); + } + }), + quickpick.onDidTriggerItemButton(e => { + if (e.button === ShowDetailsViewQuickInputButton) { + void StashActions.showDetailsView(e.item.item, { pin: false, preserveFocus: true }); + } else if (e.button === RevealInSideBarQuickInputButton) { + void StashActions.reveal(e.item.item, { + select: true, + focus: false, + expand: true, + }); + } + }), + ); + + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.items = items; + + quickpick.show(); + }); + + return pick?.item; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); + } +} diff --git a/src/system/-webview/markdown.ts b/src/system/-webview/markdown.ts new file mode 100644 index 0000000000000..b305441f38102 --- /dev/null +++ b/src/system/-webview/markdown.ts @@ -0,0 +1,24 @@ +import type { TextDocumentShowOptions, Uri } from 'vscode'; +import { workspace } from 'vscode'; +import { Logger } from '../logger'; +import { executeCoreCommand } from './command'; + +export async function showMarkdownPreview( + uriOrContent: Uri | string, + options: TextDocumentShowOptions = { + preview: false, + }, +): Promise { + try { + if (typeof uriOrContent === 'string') { + const document = await workspace.openTextDocument({ language: 'markdown', content: uriOrContent }); + + uriOrContent = document.uri; + } + + void executeCoreCommand('vscode.openWith', uriOrContent, 'vscode.markdown.preview.editor', options); + } catch (ex) { + Logger.error(ex, 'showMarkdownPreview'); + debugger; + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-commit-details.ts b/src/webviews/apps/commitDetails/components/gl-commit-details.ts index 420202ec3d0fe..5d786a3ac794a 100644 --- a/src/webviews/apps/commitDetails/components/gl-commit-details.ts +++ b/src/webviews/apps/commitDetails/components/gl-commit-details.ts @@ -21,6 +21,7 @@ import { GlDetailsBase } from './gl-details-base'; import '../../shared/components/actions/action-item'; import '../../shared/components/actions/action-nav'; import '../../shared/components/button'; +import '../../shared/components/chips/action-chip'; import '../../shared/components/code-icon'; import '../../shared/components/commit/commit-identity'; import '../../shared/components/commit/commit-stats'; @@ -151,19 +152,38 @@ export class GlCommitDetails extends GlDetailsBase { > `, )} -
+
+
+ ${when( + index === -1, + () => + html`

+ ${unsafeHTML(message)} +

`, + () => + html`

+ ${unsafeHTML(message.substring(0, index))}
${unsafeHTML(message.substring(index + 3))} +

`, + )} +
${when( - index === -1, - () => - html`

- ${unsafeHTML(message)} -

`, - () => - html`

- ${unsafeHTML(message.substring(0, index))}
${unsafeHTML(message.substring(index + 3))} html` +

+ explain -

`, +
+ `, )}
@@ -429,59 +449,6 @@ export class GlCommitDetails extends GlDetailsBase { `; } - private renderExplainAi() { - if (this.state?.orgSettings.ai === false) return undefined; - - const markdown = - this.explain?.result != null ? `${this.explain.result.summary}\n\n${this.explain.result.body}` : undefined; - - // TODO: add loading and response states - return html` - - Explain (AI) - - - - -
-

Let AI assist in understanding the changes made with this commit.

-

- - Explain - Changes - -

- ${markdown - ? html`
- -
` - : this.explain?.error - ? html`
-

- ${this.explain.error.message ?? 'Error retrieving content'} -

-
` - : undefined} -
-
- `; - } - override render(): unknown { if (this.state?.commit == null) { return this.renderEmptyContent(); @@ -495,7 +462,6 @@ export class GlCommitDetails extends GlDetailsBase { this.isStash ? 'stash' : 'commit', this.renderCommitStats(this.state.commit.stats), )} - ${this.renderExplainAi()} `; } diff --git a/src/webviews/apps/shared/components/chips/action-chip.ts b/src/webviews/apps/shared/components/chips/action-chip.ts new file mode 100644 index 0000000000000..c8cb557608f7e --- /dev/null +++ b/src/webviews/apps/shared/components/chips/action-chip.ts @@ -0,0 +1,99 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { focusOutline } from '../styles/lit/a11y.css'; +import '../overlays/tooltip'; +import '../code-icon'; + +@customElement('gl-action-chip') +export class ActionChip extends LitElement { + static override shadowRootOptions: ShadowRootInit = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + static override styles = css` + :host { + box-sizing: border-box; + display: inline-flex; + justify-content: center; + align-items: center; + min-width: 2rem; + height: 2rem; + border-radius: 0.5rem; + color: inherit; + padding: 0.2rem; + vertical-align: text-bottom; + text-decoration: none; + cursor: pointer; + } + + :host(:focus-within) { + ${focusOutline} + } + + :host(:hover) { + background-color: var(--vscode-toolbar-hoverBackground); + } + + :host(:active) { + background-color: var(--vscode-toolbar-activeBackground); + } + + :host([disabled]) { + pointer-events: none; + opacity: 0.5; + } + + a { + display: inline-flex; + justify-content: center; + align-items: center; + gap: 0.2rem; + vertical-align: middle; + color: inherit; + } + a:focus { + outline: none; + } + + ::slotted(*) { + padding-inline-end: 0.2rem; + vertical-align: middle; + text-transform: capitalize; + } + `; + + @property() + href?: string; + + @property() + label?: string; + + @property() + icon = ''; + + @property({ type: Boolean }) + disabled = false; + + @query('a') + private defaultFocusEl!: HTMLAnchorElement; + + override render(): unknown { + return html` + + + + + + `; + } + + override focus(options?: FocusOptions): void { + this.defaultFocusEl.focus(options); + } +} diff --git a/src/webviews/apps/shared/styles/details-base.scss b/src/webviews/apps/shared/styles/details-base.scss index 654ac2e35bcb7..df452f55d86d5 100644 --- a/src/webviews/apps/shared/styles/details-base.scss +++ b/src/webviews/apps/shared/styles/details-base.scss @@ -166,6 +166,15 @@ ul { } } +.message-block-actions { + background: var(--color-background--level-075); + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: 0.5rem; + border-radius: 0 0 2px 2px; +} + .commit-detail-panel { max-height: 100vh; overflow: auto; diff --git a/src/webviews/commitDetails/commitDetailsWebview.ts b/src/webviews/commitDetails/commitDetailsWebview.ts index 90cf97d6a1599..da6639fbf6dd0 100644 --- a/src/webviews/commitDetails/commitDetailsWebview.ts +++ b/src/webviews/commitDetails/commitDetailsWebview.ts @@ -1126,14 +1126,14 @@ export class CommitDetailsWebviewProvider private async explainRequest(requestType: T, msg: IpcCallMessageType) { let params: DidExplainParams; try { - const result = await this.container.ai.explainCommit( - this._context.commit!, - { source: 'inspect', type: isStash(this._context.commit) ? 'stash' : 'commit' }, - { progress: { location: { viewId: this.host.id } } }, - ); - if (result == null) throw new Error('Error retrieving content'); + const isStashCommit = isStash(this._context.commit); + await executeCommand(isStashCommit ? 'gitlens.ai.explainStash' : 'gitlens.ai.explainCommit', { + repoPath: this._context.commit!.repoPath, + ref: this._context.commit!.sha, + source: { source: 'inspect', type: isStashCommit ? 'stash' : 'commit' }, + }); - params = { result: result?.parsed }; + params = { result: { summary: '', body: '' } }; } catch (ex) { debugger; params = { error: { message: ex.message } }; diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index e5815bc4384df..1620596474d0c 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -686,6 +686,8 @@ export class GraphWebviewProvider implements WebviewProvider