diff --git a/contributions.json b/contributions.json index 25e6cda235fa1..6c64aecfc6c7d 100644 --- a/contributions.json +++ b/contributions.json @@ -49,6 +49,19 @@ ] } }, + "gitlens.ai.explainWip": { + "label": "Explain Working Changes (Preview)...", + "commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "menus": { + "view/item/context": [ + { + "when": "viewItem =~ /gitlens:worktree\\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 && config.gitlens.ai.enabled" @@ -1361,6 +1374,42 @@ ] } }, + "gitlens.graph.ai.explainCommit": { + "label": "Explain Commit", + "menus": { + "webview/context": [ + { + "when": "webviewItem =~ /gitlens:commit\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", + "group": "1_gitlens_actions_3", + "order": 1 + } + ] + } + }, + "gitlens.graph.ai.explainStash": { + "label": "Explain Stash (Preview)", + "menus": { + "webview/context": [ + { + "when": "webviewItem =~ /gitlens:stash\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", + "group": "1_gitlens_actions_3", + "order": 1 + } + ] + } + }, + "gitlens.graph.ai.explainWip": { + "label": "Explain Working Changes (Preview)", + "menus": { + "webview/context": [ + { + "when": "webviewItem =~ /gitlens:wip\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", + "group": "1_gitlens_actions_3", + "order": 1 + } + ] + } + }, "gitlens.graph.ai.generateChangelogFrom": { "label": "Generate Changelog (Preview)...", "icon": "$(sparkle)", @@ -1988,30 +2037,6 @@ ] } }, - "gitlens.graph.explainCommit": { - "label": "Explain Commit", - "menus": { - "webview/context": [ - { - "when": "webviewItem =~ /gitlens:commit\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.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 && config.gitlens.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 6fbde9de38dd3..33337f77d6aca 100644 --- a/package.json +++ b/package.json @@ -6084,6 +6084,11 @@ "title": "Explain Stash (Preview)...", "category": "GitLens" }, + { + "command": "gitlens.ai.explainWip", + "title": "Explain Working Changes (Preview)...", + "category": "GitLens" + }, { "command": "gitlens.ai.generateChangelog", "title": "Generate Changelog (Preview)...", @@ -6619,6 +6624,18 @@ "title": "Add as Co-author", "icon": "$(person-add)" }, + { + "command": "gitlens.graph.ai.explainCommit", + "title": "Explain Commit" + }, + { + "command": "gitlens.graph.ai.explainStash", + "title": "Explain Stash (Preview)" + }, + { + "command": "gitlens.graph.ai.explainWip", + "title": "Explain Working Changes (Preview)" + }, { "command": "gitlens.graph.ai.generateChangelogFrom", "title": "Generate Changelog (Preview)...", @@ -6847,14 +6864,6 @@ "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", @@ -10408,6 +10417,10 @@ "command": "gitlens.ai.explainStash", "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled" }, + { + "command": "gitlens.ai.explainWip", + "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 && config.gitlens.ai.enabled" @@ -10808,6 +10821,18 @@ "command": "gitlens.graph.addAuthor", "when": "false" }, + { + "command": "gitlens.graph.ai.explainCommit", + "when": "false" + }, + { + "command": "gitlens.graph.ai.explainStash", + "when": "false" + }, + { + "command": "gitlens.graph.ai.explainWip", + "when": "false" + }, { "command": "gitlens.graph.ai.generateChangelogFrom", "when": "false" @@ -11000,14 +11025,6 @@ "command": "gitlens.graph.deleteTag", "when": "false" }, - { - "command": "gitlens.graph.explainCommit", - "when": "false" - }, - { - "command": "gitlens.graph.explainStash", - "when": "false" - }, { "command": "gitlens.graph.fetch", "when": "false" @@ -17958,6 +17975,11 @@ "when": "viewItem =~ /gitlens:worktree\\b/ && !listMultiSelection", "group": "3_gitlens@1" }, + { + "command": "gitlens.ai.explainWip", + "when": "viewItem =~ /gitlens:worktree\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "group": "3_gitlens_ai@1" + }, { "command": "gitlens.views.deleteWorktree", "when": "viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+(active|default)\\b)/ && !listMultiSelection && !gitlens:readonly", @@ -20164,7 +20186,7 @@ "group": "1_gitlens_actions_1@4" }, { - "command": "gitlens.graph.explainCommit", + "command": "gitlens.graph.ai.explainCommit", "when": "webviewItem =~ /gitlens:commit\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", "group": "1_gitlens_actions_3@1" }, @@ -20329,7 +20351,7 @@ "group": "1_gitlens_actions@3" }, { - "command": "gitlens.graph.explainStash", + "command": "gitlens.graph.ai.explainStash", "when": "webviewItem =~ /gitlens:stash\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", "group": "1_gitlens_actions_3@1" }, @@ -20393,6 +20415,11 @@ "when": "webviewItem == gitlens:wip && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", "group": "1_gitlens_actions@4" }, + { + "command": "gitlens.graph.ai.explainWip", + "when": "webviewItem =~ /gitlens:wip\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", + "group": "1_gitlens_actions_3@1" + }, { "command": "gitlens.graph.hideRefGroup", "when": "webviewItemGroup =~ /gitlens:refGroup\\b(?!.*?\\b\\+current\\b)/", diff --git a/src/commands.ts b/src/commands.ts index 56a1b4f6d2f50..3bdb90d9413ce 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -23,6 +23,7 @@ import './commands/diffWithWorking'; import './commands/externalDiff'; import './commands/explainCommit'; import './commands/explainStash'; +import './commands/explainWip'; import './commands/generateChangelog'; import './commands/generateCommitMessage'; import './commands/ghpr/openOrCreateWorktree'; diff --git a/src/commands/commandContext.utils.ts b/src/commands/commandContext.utils.ts index ffa12c29e2792..bbced68260440 100644 --- a/src/commands/commandContext.utils.ts +++ b/src/commands/commandContext.utils.ts @@ -15,6 +15,7 @@ import { isRemote } from '../git/models/remote'; import { Repository } from '../git/models/repository'; import type { GitTag } from '../git/models/tag'; import { isTag } from '../git/models/tag'; +import { GitWorktree } from '../git/models/worktree'; import { CloudWorkspace } from '../plus/workspaces/models/cloudWorkspace'; import { LocalWorkspace } from '../plus/workspaces/models/localWorkspace'; import { isScm, isScmResourceGroup, isScmResourceState } from '../system/-webview/scm'; @@ -121,6 +122,14 @@ export function isCommandContextViewNodeHasRemote( return isRemote((context.node as ViewNode & { remote: GitRemote }).remote); } +export function isCommandContextViewNodeHasWorktree( + context: CommandContext, +): context is CommandViewNodeContext & { node: ViewNode & { worktree: GitWorktree } } { + if (context.type !== 'viewItem') return false; + + return (context.node as ViewNode & { worktree?: GitWorktree }).worktree instanceof GitWorktree; +} + export function isCommandContextViewNodeHasRepository( context: CommandContext, ): context is CommandViewNodeContext & { node: ViewNode & { repo: Repository } } { diff --git a/src/commands/explainWip.ts b/src/commands/explainWip.ts new file mode 100644 index 0000000000000..cd36192e6e169 --- /dev/null +++ b/src/commands/explainWip.ts @@ -0,0 +1,151 @@ +import type { TextEditor, Uri } from 'vscode'; +import { ProgressLocation } from 'vscode'; +import type { Container } from '../container'; +import { GitUri } from '../git/gitUri'; +import { uncommitted, uncommittedStaged } from '../git/models/revision'; +import { showGenericErrorMessage } from '../messages'; +import type { AIExplainSource } from '../plus/ai/aiProviderService'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +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 { + isCommandContextViewNodeHasRepoPath, + isCommandContextViewNodeHasRepository, + isCommandContextViewNodeHasWorktree, +} from './commandContext.utils'; + +export interface ExplainWipCommandArgs { + repoPath?: string | Uri; + staged?: boolean; + source?: AIExplainSource; + worktreePath?: string; +} + +@command() +export class ExplainWipCommand extends GlCommandBase { + constructor(private readonly container: Container) { + super('gitlens.ai.explainWip'); + } + + protected override preExecute(context: CommandContext, args?: ExplainWipCommandArgs): Promise { + if (isCommandContextViewNodeHasWorktree(context)) { + args = { ...args }; + args.repoPath = context.node.worktree.repoPath; + args.worktreePath = context.node.worktree.path; + args.source = args.source ?? { source: 'view', type: 'wip' }; + } else if (isCommandContextViewNodeHasRepository(context)) { + args = { ...args }; + args.repoPath = context.node.repo.path; + args.source = args.source ?? { source: 'view', type: 'wip' }; + } else if (isCommandContextViewNodeHasRepoPath(context)) { + args = { ...args }; + args.repoPath = context.node.repoPath; + args.source = args.source ?? { source: 'view', type: 'wip' }; + } + + return this.execute(context.editor, context.uri, args); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: ExplainWipCommandArgs): Promise { + args = { ...args }; + + let repository; + if (args?.repoPath != null) { + if (args.worktreePath) { + // TODO use await this.container.git.diff(args.worktreePath) instead, this will be more performant + repository = await this.container.git.getOrOpenRepository(args.worktreePath, { closeOnOpen: true }); + } else { + repository = this.container.git.getRepository(args.repoPath); + } + } else { + uri = getCommandUri(uri, editor); + const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; + repository = await getBestRepositoryOrShowPicker( + gitUri, + editor, + 'Explain Working Changes', + 'Choose which repository to explain working changes from', + ); + } + + if (repository == null) return; + + try { + // Get the diff of working changes + const diffService = repository.git.diff(); + if (diffService?.getDiff === undefined) { + void showGenericErrorMessage('Unable to get diff service'); + return; + } + + // If args?.staged is undefined, should we get all changes (staged and unstaged)? + let stagedLabel; + let to; + if (args?.staged === true) { + stagedLabel = 'Staged'; + to = uncommittedStaged; + } else { + stagedLabel = 'Unstaged'; + to = uncommitted; + } + + // If a worktree path is specified, use it for the diff + // const options = args?.worktreePath ? { uris: [Uri.file(args.worktreePath)] } : undefined; + const options = undefined; + const diff = await diffService.getDiff(to, undefined, options); + if (!diff?.contents) { + void showGenericErrorMessage('No working changes found to explain'); + return; + } + + // Get worktree info + let worktreeInfo = ''; + let worktreeDisplayName = ''; + + if (args?.worktreePath) { + // Get the worktree name if available + const worktrees = await repository.git.worktrees()?.getWorktrees(); + const worktree = worktrees?.find(w => w.path === args.worktreePath); + + if (worktree) { + worktreeInfo = ` in ${worktree.name}`; + worktreeDisplayName = ` (${worktree.name})`; + } else { + worktreeInfo = ` in worktree`; + worktreeDisplayName = ` (${args.worktreePath})`; + } + } + + // Call the AI service to explain the changes + const result = await this.container.ai.explainChanges( + { + diff: diff.contents, + message: `${stagedLabel} working changes${worktreeInfo}`, + }, + args.source ?? { source: 'commandPalette', type: 'wip' }, + { + progress: { + location: ProgressLocation.Notification, + title: `Explaining working changes${worktreeInfo}...`, + }, + }, + ); + + const title = `Working Changes Summary${worktreeDisplayName}`; + let content = `# ${title}\n\n`; + if (result != null) { + content += `> Generated by ${result.model.name}\n\n## ${stagedLabel} Changes\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, 'ExplainWipCommand', 'execute'); + void showGenericErrorMessage('Unable to explain working changes'); + } + } +} diff --git a/src/constants.commands.generated.ts b/src/constants.commands.generated.ts index 3f852a1edec7f..cfb9a3396f57b 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -23,6 +23,9 @@ export type ContributedCommands = | 'gitlens.copyRemoteRepositoryUrl' | 'gitlens.ghpr.views.openOrCreateWorktree' | 'gitlens.graph.addAuthor' + | 'gitlens.graph.ai.explainCommit' + | 'gitlens.graph.ai.explainStash' + | 'gitlens.graph.ai.explainWip' | 'gitlens.graph.ai.generateChangelogFrom' | 'gitlens.graph.ai.generateCommitMessage' | 'gitlens.graph.associateIssueWithBranch' @@ -71,8 +74,6 @@ 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' @@ -615,6 +616,7 @@ export type ContributedPaletteCommands = | 'gitlens.addAuthors' | 'gitlens.ai.explainCommit' | 'gitlens.ai.explainStash' + | 'gitlens.ai.explainWip' | 'gitlens.ai.generateChangelog' | 'gitlens.ai.generateCommitMessage' | 'gitlens.applyPatchFromClipboard' diff --git a/src/constants.commands.ts b/src/constants.commands.ts index c5e9175d919b7..6fe9520a72d54 100644 --- a/src/constants.commands.ts +++ b/src/constants.commands.ts @@ -54,6 +54,7 @@ type InternalHomeWebviewCommands = | 'gitlens.home.skipPausedOperation' | 'gitlens.home.continuePausedOperation' | 'gitlens.home.abortPausedOperation' + | 'gitlens.home.explainWip' | 'gitlens.home.openRebaseEditor'; type InternalHomeWebviewViewCommands = diff --git a/src/webviews/apps/plus/home/components/branch-card.ts b/src/webviews/apps/plus/home/components/branch-card.ts index 0a00fe2845ab4..bbb81b749298b 100644 --- a/src/webviews/apps/plus/home/components/branch-card.ts +++ b/src/webviews/apps/plus/home/components/branch-card.ts @@ -1000,6 +1000,16 @@ export class GlBranchCard extends GlBranchCardBase { href=${this.createCommandLink('gitlens.home.openWorktree')} >`, ); + // add explain WIP + if (this.wip?.workingTreeState != null) { + actions.push( + html``, + ); + } } else { actions.push( html`({ args: { 0: r => r.branchId } }) + private async explainWip(ref: BranchRef) { + const { repo, branch } = await this.getRepoInfoFromRef(ref); + if (repo == null) return; + + const worktree = await branch?.getWorktree(); + + void executeCommand('gitlens.ai.explainWip', { + repoPath: repo.path, + worktreePath: worktree?.path, + source: { source: 'home', type: 'wip' }, + }); + } + @log() private startWork() { this.container.telemetry.sendEvent('home/startWork'); diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index 2bd023ef80f01..7dcfc62226373 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -685,8 +685,9 @@ export class GraphWebviewProvider implements WebviewProvider