diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore index 5889588..7b25b2e 100644 --- a/extensions/vscode/.vscodeignore +++ b/extensions/vscode/.vscodeignore @@ -1,19 +1,34 @@ +# Development/editor state .vscode/** .vscode-test/** -src/** -!src/**/*.d.ts -!src/ui/webview/gitpilotWorkspaceTemplate.html -!src/ui/webview/gitpilotWorkspace.css -**/*.map .gitignore + +# Source and build tooling are not runtime assets. +src/** +scripts/** +Makefile +EXTENSION_DOCS.md tsconfig.json +package-lock.json node_modules/** + +# TypeScript/source maps and temporary files. **/*.ts -!out/** -# Exclude Windows "Copy" artifacts (e.g., "extension copy.ts") +**/*.tsx +**/*.map **/* copy*.ts **/* copy*.tsx **/* copy*.js **/*.copy.ts **/*.backup.ts -**/*.bak.ts \ No newline at end of file +**/*.bak.ts + +# Previously generated packages should not be nested in new packages. +*.vsix + +# Keep compiled runtime output and public assets. +!out/** +!resources/** +!README.md +!CHANGELOG.md +!LICENSE diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index f08c035..784653f 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -97,11 +97,13 @@ }, { "id": "gitpilot.sessionsView", - "name": "Sessions" + "name": "Sessions", + "visibility": "collapsed" }, { "id": "gitpilot.skillsView", - "name": "Skills & Plugins" + "name": "Skills & Plugins", + "visibility": "collapsed" } ] }, @@ -726,17 +728,24 @@ ] }, "scripts": { - "vscode:prepublish": "npm run compile", + "vscode:prepublish": "npm run compile && node scripts/remove-source-maps.js", "compile": "tsc -p ./ && node scripts/copy-webview-assets.js", "watch": "tsc -watch -p ./", - "lint": "eslint src --ext ts", + "lint": "node scripts/lint.js", "package": "vsce package", - "publish": "vsce publish" + "publish": "vsce publish", + "reinstall-vsix": "npm run package && node scripts/install-latest-vsix.js" }, "devDependencies": { "@types/node": "^20.19.39", "@types/vscode": "^1.110.0", "@vscode/vsce": "^2.22.0", "typescript": "^5.9.3" + }, + "capabilities": { + "untrustedWorkspaces": { + "supported": "limited", + "description": "GitPilot can inspect trusted-safe workspace metadata in restricted mode, but blocks file modifications, terminal execution, package/plugin installation, commits, pushes, and other generated tool execution until the workspace is trusted." + } } } diff --git a/extensions/vscode/scripts/copy-webview-assets.js b/extensions/vscode/scripts/copy-webview-assets.js index 0cd018d..0dc2235 100644 --- a/extensions/vscode/scripts/copy-webview-assets.js +++ b/extensions/vscode/scripts/copy-webview-assets.js @@ -11,6 +11,10 @@ const filesToCopy = [ from: path.join(root, "src", "ui", "webview", "gitpilotWorkspace.css"), to: path.join(root, "out", "ui", "webview", "gitpilotWorkspace.css"), }, + { + from: path.join(root, "src", "ui", "webview", "gitpilotWorkspace.js"), + to: path.join(root, "out", "ui", "webview", "gitpilotWorkspace.js"), + }, { from: path.join(root, "src", "ui", "webview", "gitpilotSettingsTemplate.html"), to: path.join(root, "out", "ui", "webview", "gitpilotSettingsTemplate.html"), diff --git a/extensions/vscode/scripts/install-latest-vsix.js b/extensions/vscode/scripts/install-latest-vsix.js new file mode 100644 index 0000000..86ce338 --- /dev/null +++ b/extensions/vscode/scripts/install-latest-vsix.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const vsix = fs + .readdirSync(root) + .filter((name) => name.endsWith(".vsix")) + .map((name) => ({ name, mtime: fs.statSync(path.join(root, name)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime)[0]; + +if (!vsix) { + console.error("No .vsix file found. Run `npm run package` first."); + process.exit(1); +} + +const vsixPath = path.join(root, vsix.name); +console.log(`Installing ${vsixPath} ...`); + +const result = spawnSync("code", ["--install-extension", vsixPath, "--force"], { + cwd: root, + stdio: "inherit", + shell: process.platform === "win32", +}); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +process.exit(result.status ?? 0); diff --git a/extensions/vscode/scripts/lint.js b/extensions/vscode/scripts/lint.js new file mode 100644 index 0000000..9dbc79a --- /dev/null +++ b/extensions/vscode/scripts/lint.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/* + * Lightweight local lint gate for the VS Code extension. + * + * This intentionally avoids a global eslint dependency so `npm run lint` works + * on Windows after `npm install` with the dependencies already in package-lock. + * It performs the highest-signal local checks that are always available: + * TypeScript no-emit validation and a few repository hygiene checks. + */ +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const srcRoot = path.join(root, "src"); +const isWindows = process.platform === "win32"; +const tscBin = path.join( + root, + "node_modules", + ".bin", + isWindows ? "tsc.cmd" : "tsc" +); + +function walk(dir, files = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === "out") { + continue; + } + + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(absolute, files); + } else if (entry.isFile() && absolute.endsWith(".ts")) { + files.push(absolute); + } + } + return files; +} + +function fail(message) { + console.error(`[lint] ${message}`); + process.exitCode = 1; +} + +if (!fs.existsSync(tscBin)) { + fail("TypeScript is not installed. Run `npm install` in extensions/vscode first."); +} else { + const result = spawnSync(tscBin, ["--noEmit", "-p", root], { + cwd: root, + stdio: "inherit", + shell: false, + }); + if (result.status !== 0) { + fail("TypeScript no-emit validation failed."); + } +} + +for (const file of walk(srcRoot)) { + const text = fs.readFileSync(file, "utf8"); + const relative = path.relative(root, file).replace(/\\/g, "/"); + + if (/\r\n/.test(text)) { + fail(`${relative}: use LF line endings.`); + } + + if (/[ \t]+$/m.test(text)) { + fail(`${relative}: remove trailing whitespace.`); + } + + if (!text.endsWith("\n")) { + fail(`${relative}: add a trailing newline.`); + } + + if (/try\s*\{\s*(?:import\s|(?:const|let|var)\s+[^=]+\s*=\s*require\()/m.test(text)) { + fail(`${relative}: do not wrap imports in try/catch blocks.`); + } +} + +if (process.exitCode) { + process.exit(process.exitCode); +} + +console.log("[lint] TypeScript and repository hygiene checks passed."); diff --git a/extensions/vscode/scripts/remove-source-maps.js b/extensions/vscode/scripts/remove-source-maps.js new file mode 100644 index 0000000..0b78d5a --- /dev/null +++ b/extensions/vscode/scripts/remove-source-maps.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); + +const outDir = path.resolve(__dirname, "..", "out"); +let removed = 0; + +function walk(dir) { + if (!fs.existsSync(dir)) { + return; + } + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(absolute); + } else if (entry.isFile() && entry.name.endsWith(".map")) { + fs.rmSync(absolute, { force: true }); + removed += 1; + } + } +} + +walk(outDir); +console.log(`Removed ${removed} source map file(s) from ${path.relative(process.cwd(), outDir) || outDir}.`); diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index b9911c1..6826bfe 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -67,6 +67,8 @@ import { ProjectContextService } from "./services/context/projectContextService" import { WorkingSetService } from "./services/context/workingSetService"; import { ContextAssembler } from "./services/context/contextAssembler"; import { ProjectIndexCache } from "./services/context/projectIndexCache"; +import { DiffService } from "./services/patch/diffService"; +import { PatchApplier } from "./services/patch/patchApplier"; import { detectIntent, @@ -175,6 +177,8 @@ export function activate(context: vscode.ExtensionContext): void { const workingSetService = new WorkingSetService(); const contextAssembler = new ContextAssembler(); const projectIndexCache = new ProjectIndexCache(); + const diffService = new DiffService(); + const patchApplier = new PatchApplier(); const sessionCoordinator = new SessionCoordinator( sessionClient, @@ -259,12 +263,65 @@ export function activate(context: vscode.ExtensionContext): void { return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; }; + const resolveWorkspaceFileUri = ( + workspaceRoot: string, + requestedPath: string + ): vscode.Uri | undefined => { + if (!requestedPath || path.isAbsolute(requestedPath)) { + return undefined; + } + + const normalizedRoot = path.resolve(workspaceRoot); + const resolvedPath = path.resolve(normalizedRoot, requestedPath); + const relativePath = path.relative(normalizedRoot, resolvedPath); + + if ( + relativePath === "" || + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + return undefined; + } + + try { + const realRoot = fs.realpathSync.native(normalizedRoot); + const realTarget = fs.existsSync(resolvedPath) + ? fs.realpathSync.native(resolvedPath) + : fs.realpathSync.native(path.dirname(resolvedPath)); + const realRelative = path.relative(realRoot, realTarget); + + if ( + realRelative === ".." || + realRelative.startsWith(`..${path.sep}`) || + path.isAbsolute(realRelative) + ) { + return undefined; + } + } catch { + return undefined; + } + + return vscode.Uri.file(resolvedPath); + }; + const isWorkspaceTrusted = (): boolean => { try { return vscode.workspace.isTrusted; } catch { + return false; + } + }; + + const requireWorkspaceTrust = (operation: string): boolean => { + if (isWorkspaceTrusted()) { return true; } + + vscode.window.showWarningMessage( + `GitPilot blocked ${operation} because this workspace is not trusted.` + ); + return false; }; const findGitRoot = (startPath?: string): string | undefined => { @@ -1648,9 +1705,19 @@ export function activate(context: vscode.ExtensionContext): void { return; } - const fileUri = vscode.Uri.file( - path.join(folderPath, msg.payload.path) + const fileUri = resolveWorkspaceFileUri( + folderPath, + msg.payload.path ); + if (!fileUri) { + postErrorToPanel({ + code: "INVALID_WORKSPACE_PATH", + title: "Open File Failed", + message: "The requested file path is outside the workspace.", + }); + return; + } + await vscode.commands.executeCommand("vscode.open", fileUri); return; } @@ -1681,9 +1748,19 @@ export function activate(context: vscode.ExtensionContext): void { return; } - const fileUri = vscode.Uri.file( - path.join(folderPath, msg.payload.path) + const fileUri = resolveWorkspaceFileUri( + folderPath, + msg.payload.path ); + if (!fileUri) { + postErrorToPanel({ + code: "INVALID_WORKSPACE_PATH", + title: "Reveal Failed", + message: "The requested file path is outside the workspace.", + }); + return; + } + await vscode.commands.executeCommand("revealInExplorer", fileUri); return; } @@ -1866,6 +1943,10 @@ export function activate(context: vscode.ExtensionContext): void { }); registerCommand("gitpilot.runCommand", async () => { + if (!requireWorkspaceTrust("terminal command execution")) { + return; + } + const command = await vscode.window.showInputBox({ prompt: "Enter command to run via GitPilot", placeHolder: "e.g. npm test", @@ -1899,12 +1980,32 @@ export function activate(context: vscode.ExtensionContext): void { return; } - const filePath = path.join(folderPath, relativePath); - const fileUri = vscode.Uri.file(filePath); - await vscode.commands.executeCommand("vscode.open", fileUri); + const edit = stateStore.state.activeTask?.edits?.find( + (candidate) => candidate.file === relativePath + ); + + if (!edit) { + vscode.window.showWarningMessage( + "Unable to open diff. No proposed change was found for the target file." + ); + return; + } + + try { + await diffService.openDiff(folderPath, edit); + } catch (error) { + appendOutputError("[GitPilot] Failed to open diff", error); + vscode.window.showWarningMessage( + "Unable to open diff. The target file is outside the workspace or unavailable." + ); + } }); registerCommand("gitpilot.applyProposedChanges", async () => { + if (!requireWorkspaceTrust("file modification")) { + return; + } + const task = stateStore.state.activeTask; const edits = task?.edits || []; const folderPath = stateStore.state.workspace.folderPath; @@ -1921,8 +2022,6 @@ export function activate(context: vscode.ExtensionContext): void { try { stateStore.setTaskStatus("applying"); - const { PatchApplier } = await import("./services/patch/patchApplier"); - const patchApplier = new PatchApplier(); const result = await patchApplier.apply(folderPath, edits); if (result.success) { @@ -1945,8 +2044,10 @@ export function activate(context: vscode.ExtensionContext): void { const firstPath = typeof firstFile === "string" ? firstFile : (firstFile as { path?: string })?.path || ""; - const fileUri = vscode.Uri.file(path.join(folderPath, firstPath)); - void vscode.commands.executeCommand("vscode.open", fileUri); + const fileUri = resolveWorkspaceFileUri(folderPath, firstPath); + if (fileUri) { + void vscode.commands.executeCommand("vscode.open", fileUri); + } } output.appendLine( @@ -1966,6 +2067,41 @@ export function activate(context: vscode.ExtensionContext): void { } }); + registerCommand("gitpilot.revertProposedChanges", async () => { + if (!requireWorkspaceTrust("file modification")) { + return; + } + + const folderPath = stateStore.state.workspace.folderPath; + if (!folderPath) { + vscode.window.showWarningMessage("No workspace folder open."); + return; + } + + try { + stateStore.setTaskStatus("applying"); + const result = await patchApplier.revert(folderPath); + if (result.success) { + stateStore.updateActiveTask({ + ...(stateStore.state.activeTask || {}), + status: "done", + summary: `Reverted ${result.appliedFiles.length} file(s).`, + }); + void vscode.commands.executeCommand("gitpilot.refreshProjectContext"); + vscode.window.showInformationMessage( + `GitPilot reverted ${result.appliedFiles.length} file(s).` + ); + } else { + stateStore.setTaskStatus("failed"); + vscode.window.showWarningMessage("GitPilot could not revert all files."); + } + } catch (err) { + stateStore.setTaskStatus("failed"); + appendOutputError("[GitPilot] Revert failed", err); + vscode.window.showErrorMessage(`Failed to revert changes: ${err}`); + } + }); + registerCommand("gitpilot.regenerateTaskPlan", async () => { const task = stateStore.state.activeTask; if (!task?.title && !task?.summary) { @@ -1978,6 +2114,30 @@ export function activate(context: vscode.ExtensionContext): void { await sendChatToBackend(`[Regenerate plan] ${prompt}`); }); + registerCommand("gitpilot.executeApprovedPlan", async () => { + const task = stateStore.state.activeTask; + const plan = task?.plan; + if (!plan) { + vscode.window.showInformationMessage("No approved GitPilot plan is ready to execute."); + return; + } + + const planLines = plan.steps + .map((step) => `${step.step}. ${step.title}: ${step.description}`) + .join("\n"); + + await sendChatToBackend( + [ + "[Execute approved plan]", + plan.goal, + plan.summary, + planLines, + ] + .filter(Boolean) + .join("\n\n") + ); + }); + registerCommand("gitpilot.explain_project", async () => { const prompt = await buildQuickActionPrompt("explain_project"); if (prompt) { @@ -2159,4 +2319,4 @@ async function initializeWorkspaceState(args: { `[GitPilot] Failed to detect initial git context: ${String(error)}` ); } -} \ No newline at end of file +} diff --git a/extensions/vscode/src/services/context/contextAssembler.ts b/extensions/vscode/src/services/context/contextAssembler.ts index fbe6feb..b6b5e37 100644 --- a/extensions/vscode/src/services/context/contextAssembler.ts +++ b/extensions/vscode/src/services/context/contextAssembler.ts @@ -123,4 +123,4 @@ export class ContextAssembler { return sections.join("\n\n---\n\n"); } -} \ No newline at end of file +} diff --git a/extensions/vscode/src/services/patch/diffService.ts b/extensions/vscode/src/services/patch/diffService.ts index 8a3801c..a874a47 100644 --- a/extensions/vscode/src/services/patch/diffService.ts +++ b/extensions/vscode/src/services/patch/diffService.ts @@ -1,25 +1,33 @@ import * as vscode from "vscode"; -import * as path from "path"; import type { ProposedEdit } from "../../core/types"; +import { PatchValidationService } from "./patchValidationService"; export class DiffService { + constructor(private readonly validator = new PatchValidationService()) {} + async openFile(workspaceRoot: string | undefined, relativePath: string): Promise { if (!workspaceRoot) return; - const uri = vscode.Uri.file(path.join(workspaceRoot, relativePath)); + const targetPath = this.validator.resolveSafePath(workspaceRoot, relativePath); + if (!targetPath) throw new Error("Path escapes the workspace"); + const uri = vscode.Uri.file(targetPath); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc, { preview: false }); } async revealFile(workspaceRoot: string | undefined, relativePath: string): Promise { if (!workspaceRoot) return; - const uri = vscode.Uri.file(path.join(workspaceRoot, relativePath)); + const targetPath = this.validator.resolveSafePath(workspaceRoot, relativePath); + if (!targetPath) throw new Error("Path escapes the workspace"); + const uri = vscode.Uri.file(targetPath); await vscode.commands.executeCommand("revealInExplorer", uri); } async openDiff(workspaceRoot: string | undefined, edit: ProposedEdit): Promise { if (!workspaceRoot) return; - const left = vscode.Uri.file(path.join(workspaceRoot, edit.file)); - const right = vscode.Uri.parse(`untitled:${path.join(workspaceRoot, edit.file)}.gitpilot-preview`); + const targetPath = this.validator.resolveSafePath(workspaceRoot, edit.file); + if (!targetPath) throw new Error("Path escapes the workspace"); + const left = vscode.Uri.file(targetPath); + const right = vscode.Uri.parse(`untitled:${targetPath}.gitpilot-preview`); const content = edit.content || edit.diff || "No preview available."; const doc = await vscode.workspace.openTextDocument(right); const editor = await vscode.window.showTextDocument(doc, { preview: false }); diff --git a/extensions/vscode/src/services/patch/patchApplier.ts b/extensions/vscode/src/services/patch/patchApplier.ts index 328bb69..bca9684 100644 --- a/extensions/vscode/src/services/patch/patchApplier.ts +++ b/extensions/vscode/src/services/patch/patchApplier.ts @@ -17,30 +17,55 @@ export class PatchApplier { return { success: false, appliedFiles: [], failedFiles: edits.map((e) => ({ file: e.file, reason: "No workspace root" })) }; } - for (const edit of edits) { - if (!this.validator.validateFilePath(workspaceRoot, edit.file)) { - result.success = false; - result.failedFiles.push({ file: edit.file, reason: "Unsafe path" }); - continue; - } + const preparedEdits = edits.map((edit) => ({ + edit, + targetPath: this.validator.resolveSafePath(workspaceRoot, edit.file), + })); + + const unsafeEdit = preparedEdits.find((item) => !item.targetPath); + if (unsafeEdit) { + return { + success: false, + appliedFiles: [], + failedFiles: [{ file: unsafeEdit.edit.file, reason: "Unsafe path" }], + }; + } + + for (const { edit, targetPath } of preparedEdits) { + const fileUri = vscode.Uri.file(targetPath!); try { - const fileUri = vscode.Uri.file(path.join(workspaceRoot, edit.file)); - let original: StoredOriginalFile = { file: edit.file, existed: false }; - try { - original = { file: edit.file, existed: true, content: await vscode.workspace.fs.readFile(fileUri) }; - } catch { - original = { file: edit.file, existed: false }; - } + const original = await this.captureOriginalFile(edit.file, fileUri); this.lastOriginals.set(edit.file, original); + } catch (error) { + this.lastOriginals.clear(); + return { + success: false, + appliedFiles: [], + failedFiles: [{ file: edit.file, reason: String(error) }], + }; + } + } + + for (const { edit, targetPath } of preparedEdits) { + const fileUri = vscode.Uri.file(targetPath!); + try { + await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.dirname(targetPath!))); const bytes = Buffer.from(edit.content || "", "utf8"); await vscode.workspace.fs.writeFile(fileUri, bytes); result.appliedFiles.push(edit.file); } catch (error) { result.success = false; result.failedFiles.push({ file: edit.file, reason: String(error) }); + await this.rollbackAppliedFiles(workspaceRoot, result.appliedFiles); + break; } } + if (!result.success) { + this.lastOriginals.clear(); + result.appliedFiles = []; + } + return result; } @@ -54,11 +79,16 @@ export class PatchApplier { for (const original of originals) { try { - const fileUri = vscode.Uri.file(path.join(workspaceRoot, original.file)); + const targetPath = this.validator.resolveSafePath(workspaceRoot, original.file); + if (!targetPath) { + throw new Error("Unsafe path"); + } + + const fileUri = vscode.Uri.file(targetPath); if (original.existed && original.content) { await vscode.workspace.fs.writeFile(fileUri, original.content); } else { - await vscode.workspace.fs.delete(fileUri, { recursive: false, useTrash: false }); + await vscode.workspace.fs.delete(fileUri, { recursive: false, useTrash: true }); } result.appliedFiles.push(original.file); } catch (error) { @@ -70,4 +100,40 @@ export class PatchApplier { if (result.success) this.lastOriginals.clear(); return result; } + + private async captureOriginalFile( + file: string, + fileUri: vscode.Uri + ): Promise { + try { + return { file, existed: true, content: await vscode.workspace.fs.readFile(fileUri) }; + } catch { + return { file, existed: false }; + } + } + + private async rollbackAppliedFiles( + workspaceRoot: string, + appliedFiles: string[] + ): Promise { + for (const file of [...appliedFiles].reverse()) { + const original = this.lastOriginals.get(file); + const targetPath = this.validator.resolveSafePath(workspaceRoot, file); + if (!original || !targetPath) { + continue; + } + + const fileUri = vscode.Uri.file(targetPath); + try { + if (original.existed && original.content) { + await vscode.workspace.fs.writeFile(fileUri, original.content); + } else { + await vscode.workspace.fs.delete(fileUri, { recursive: false, useTrash: true }); + } + } catch { + // Best-effort rollback. The original apply failure remains the + // user-visible error while subsequent revert can address leftovers. + } + } + } } diff --git a/extensions/vscode/src/services/patch/patchValidationService.ts b/extensions/vscode/src/services/patch/patchValidationService.ts index 7d5f12d..f3359c7 100644 --- a/extensions/vscode/src/services/patch/patchValidationService.ts +++ b/extensions/vscode/src/services/patch/patchValidationService.ts @@ -1,9 +1,79 @@ import * as path from "path"; +import * as fs from "fs"; export class PatchValidationService { + resolveSafePath(workspaceRoot: string | undefined, candidate: string): string | undefined { + if (!workspaceRoot || !candidate || path.isAbsolute(candidate)) { + return undefined; + } + + const root = path.resolve(workspaceRoot); + const target = path.resolve(root, candidate); + const relative = path.relative(root, target); + + if ( + relative === "" || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + return undefined; + } + + const realRoot = this.safeRealpath(root); + if (!realRoot) { + return undefined; + } + + const realTarget = this.resolveExistingRealpath(target); + if (!realTarget || !this.isWithinRoot(realRoot, realTarget)) { + return undefined; + } + + return target; + } + validateFilePath(workspaceRoot: string | undefined, candidate: string): boolean { - if (!workspaceRoot) return false; - const target = path.resolve(workspaceRoot, candidate); - return target.startsWith(path.resolve(workspaceRoot)); + return Boolean(this.resolveSafePath(workspaceRoot, candidate)); + } + + private isWithinRoot(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative !== "" && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); + } + + private safeRealpath(target: string): string | undefined { + try { + return fs.realpathSync.native(target); + } catch { + return undefined; + } + } + + private resolveExistingRealpath(target: string): string | undefined { + let current = target; + const missingSegments: string[] = []; + + while (!fs.existsSync(current)) { + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + + missingSegments.unshift(path.basename(current)); + current = parent; + } + + const realExistingPath = this.safeRealpath(current); + if (!realExistingPath) { + return undefined; + } + + return path.resolve(realExistingPath, ...missingSegments); } } diff --git a/extensions/vscode/src/ui/webview/GitPilotPanel.ts b/extensions/vscode/src/ui/webview/GitPilotPanel.ts index 47f1991..34ad9af 100644 --- a/extensions/vscode/src/ui/webview/GitPilotPanel.ts +++ b/extensions/vscode/src/ui/webview/GitPilotPanel.ts @@ -20,7 +20,7 @@ export class GitPilotPanel constructor( private readonly _extensionUri: vscode.Uri, private readonly _stateStore: StateStore, - private readonly _onMessage: (msg: WebviewToExtensionMessage) => void + private readonly _onMessage: (msg: WebviewToExtensionMessage) => unknown | Promise ) { this._output = vscode.window.createOutputChannel("GitPilot"); this._disposables.push(this._output); @@ -36,10 +36,8 @@ export class GitPilotPanel webviewView.webview.options = { enableScripts: true, localResourceRoots: [ - this._extensionUri, vscode.Uri.joinPath(this._extensionUri, "out"), vscode.Uri.joinPath(this._extensionUri, "resources"), - vscode.Uri.joinPath(this._extensionUri, "src"), ], }; @@ -51,6 +49,7 @@ export class GitPilotPanel if (msg.type === "INIT") { this._log("Received INIT from webview"); this._syncState(); + await this._onMessage(msg); return; } @@ -65,7 +64,7 @@ export class GitPilotPanel } this._log(`Received message from webview: ${msg.type}`); - this._onMessage(msg); + await this._onMessage(msg); } catch (error) { this._logError("Error while handling message from webview", error); void vscode.window.showErrorMessage( @@ -161,6 +160,7 @@ export class GitPilotPanel this._log(`Loading webview template from: ${templateUri.toString()}`); const cssUri = await this._resolveCssUri(webview); + const scriptUri = await this._resolveScriptUri(webview); const templateBytes = await vscode.workspace.fs.readFile(templateUri); const template = new TextDecoder("utf-8").decode(templateBytes); @@ -168,7 +168,8 @@ export class GitPilotPanel return template .replace(/__CSP__/g, this._escapeHtml(csp)) .replace(/__NONCE__/g, nonce) - .replace(/__CSS_URI__/g, cssUri.toString()); + .replace(/__CSS_URI__/g, cssUri.toString()) + .replace(/__SCRIPT_URI__/g, scriptUri.toString()); } private async _resolveTemplateUri(): Promise { @@ -239,6 +240,40 @@ export class GitPilotPanel ); } + private async _resolveScriptUri(webview: vscode.Webview): Promise { + const candidates = [ + vscode.Uri.joinPath( + this._extensionUri, + "out", + "ui", + "webview", + "gitpilotWorkspace.js" + ), + vscode.Uri.joinPath( + this._extensionUri, + "src", + "ui", + "webview", + "gitpilotWorkspace.js" + ), + ]; + + for (const candidate of candidates) { + if (await this._exists(candidate)) { + return webview.asWebviewUri(candidate); + } + } + + throw new Error( + [ + "GitPilot webview script was not found.", + "Expected one of:", + ...candidates.map((uri) => ` - ${uri.fsPath}`), + "Ensure gitpilotWorkspace.js is included in the packaged extension.", + ].join("\n") + ); + } + private async _exists(uri: vscode.Uri): Promise { try { await vscode.workspace.fs.stat(uri); @@ -734,4 +769,4 @@ export class GitPilotPanel return value; } -} \ No newline at end of file +} diff --git a/extensions/vscode/src/ui/webview/gitpilotWorkspace.css b/extensions/vscode/src/ui/webview/gitpilotWorkspace.css index 67af0c0..537a859 100644 --- a/extensions/vscode/src/ui/webview/gitpilotWorkspace.css +++ b/extensions/vscode/src/ui/webview/gitpilotWorkspace.css @@ -1376,3 +1376,1062 @@ details[open] .chevron { justify-content: flex-start; } } + +/* ========================================================================== */ +/* GitPilot compact sidebar layout */ +/* */ +/* These rules intentionally extend the existing GitPilot component system. */ +/* The original chat, thinking, streaming, success, error and activity */ +/* animations above remain active; this section only changes layout and adds */ +/* brand-consistent motion used by the redesigned sidebar. */ +/* ========================================================================== */ + +:root { + --gp-sidebar-accent: var(--gp-accent, #d95c3d); + --gp-sidebar-accent-bright: var(--gp-accent-bright, #ff7a3c); + --gp-sidebar-accent-soft: color-mix(in srgb, var(--gp-sidebar-accent) 13%, transparent); + --gp-sidebar-accent-faint: color-mix(in srgb, var(--gp-sidebar-accent) 6%, transparent); + --gp-sidebar-radius-xs: 3px; + --gp-sidebar-radius-sm: 5px; + --gp-sidebar-radius-md: 8px; + --gp-sidebar-space-1: 4px; + --gp-sidebar-space-2: 8px; + --gp-sidebar-space-3: 12px; + --gp-sidebar-space-4: 16px; + --gp-sidebar-border: color-mix(in srgb, var(--vscode-panel-border) 82%, transparent); + --gp-sidebar-surface: color-mix( + in srgb, + var(--vscode-editorWidget-background, var(--vscode-sideBar-background)) 86%, + var(--vscode-sideBar-background) + ); + --gp-sidebar-surface-raised: color-mix( + in srgb, + var(--vscode-editor-background, var(--vscode-sideBar-background)) 90%, + var(--gp-sidebar-accent-faint) + ); +} + +html, +body { + width: 100%; + height: 100%; + min-width: 0; + margin: 0; + padding: 0; + overflow: hidden; + background: var(--vscode-sideBar-background); +} + +body { + color: var(--vscode-foreground); +} + +.gp-sidebar { + width: 100%; + height: 100vh; + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + background: + radial-gradient( + circle at 52% 38%, + color-mix(in srgb, var(--gp-sidebar-accent) 3%, transparent), + transparent 38% + ), + var(--vscode-sideBar-background); + color: var(--vscode-foreground); +} + +/* Header ------------------------------------------------------------------ */ + +.gp-sidebar-header { + position: relative; + z-index: 4; + display: grid; + gap: 6px; + min-width: 0; + padding: 12px 14px 11px; + border-bottom: 1px solid var(--gp-sidebar-border); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--vscode-sideBar-background) 96%, var(--gp-sidebar-accent-faint)), + var(--vscode-sideBar-background) + ); +} + +.gp-title-row, +.gp-repository-row { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.gp-product { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.gp-product-name { + overflow: hidden; + color: var(--vscode-foreground); + font-size: 13px; + font-weight: 750; + letter-spacing: -0.01em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gp-connection-dot, +.gp-status-dot { + position: relative; + width: 8px; + height: 8px; + flex: 0 0 auto; + border-radius: 999px; + background: var(--success); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 30%, transparent); +} + +.gp-connection-dot::after, +.gp-status-dot::after { + content: ""; + position: absolute; + inset: -4px; + border: 1px solid currentColor; + border-radius: inherit; + opacity: 0; + transform: scale(0.55); + pointer-events: none; +} + +.gp-connection-dot.is-connected, +.gp-status-dot.is-ready { + color: var(--success); + background: var(--success); + animation: gpConnectionGlow 2.8s ease-in-out infinite; +} + +.gp-connection-dot.is-connected::after, +.gp-status-dot.is-ready::after { + animation: gpStatusRing 2.8s ease-out infinite; +} + +.gp-connection-dot.is-degraded, +.gp-status-dot.is-working { + color: var(--gp-sidebar-accent-bright); + background: var(--gp-sidebar-accent-bright); + animation: gpWorkingPulse 1.35s ease-in-out infinite; +} + +.gp-connection-dot.is-degraded::after, +.gp-status-dot.is-working::after { + animation: gpStatusRing 1.35s ease-out infinite; +} + +.gp-connection-dot.is-disconnected, +.gp-status-dot.is-error { + color: var(--danger); + background: var(--danger); + animation: none; +} + +.gp-toolbar { + display: inline-flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; +} + +.gp-icon-btn { + width: 26px; + height: 26px; + min-width: 26px; + display: inline-grid; + place-items: center; + padding: 0; + border: 1px solid transparent; + border-radius: var(--gp-sidebar-radius-sm); + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: 14px; + line-height: 1; + transition: + color 120ms ease, + background 120ms ease, + border-color 120ms ease, + transform 120ms ease; +} + +.gp-icon-btn:hover { + border-color: var(--gp-sidebar-border); + background: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-foreground); + transform: translateY(-1px); +} + +.gp-icon-btn:active { + transform: translateY(0); +} + +.gp-provider-line { + justify-self: start; + max-width: 100%; + min-width: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: 12px; + line-height: 1.35; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gp-provider-line:hover { + background: transparent; + color: var(--vscode-foreground); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--gp-sidebar-accent) 55%, transparent); + text-underline-offset: 3px; +} + +.gp-repository-line { + min-width: 0; + overflow: hidden; + color: var(--vscode-descriptionForeground); + font-size: 12px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gp-index-status { + min-width: 26px; + min-height: 24px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 5px; + border-radius: var(--gp-sidebar-radius-sm); + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.gp-index-status:hover { + background: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-foreground); +} + +.gp-index-check { + color: var(--success); +} + +.gp-index-refresh { + display: inline-block; + color: var(--gp-sidebar-accent-bright); + transform-origin: center; +} + +.gp-index-status:hover .gp-index-refresh { + animation: spin 0.75s ease-in-out; +} + +@media (max-width: 280px) { + .gp-index-label { + display: none; + } +} + +/* Conversation ------------------------------------------------------------ */ + +.gp-conversation { + min-width: 0; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + scroll-behavior: smooth; + padding: 12px; +} + +.gp-chat-section { + min-height: 100%; + display: grid; + align-content: start; +} + +.chat-list { + max-height: none; + overflow: visible; + gap: 10px; + padding: 0; +} + +.chat-item { + border-radius: var(--gp-sidebar-radius-md); + border-left-width: 2px; + background: var(--gp-sidebar-surface); + box-shadow: none; +} + +.chat-item.user { + border-left-color: var(--gp-sidebar-accent); + background: color-mix(in srgb, var(--gp-sidebar-surface) 94%, var(--gp-sidebar-accent-soft)); +} + +.chat-item.assistant { + border-left-color: color-mix(in srgb, var(--gp-sidebar-accent-bright) 68%, var(--success)); +} + +.chat-item.thinking { + isolation: isolate; + background: + linear-gradient( + 100deg, + color-mix(in srgb, var(--gp-sidebar-surface) 97%, transparent), + color-mix(in srgb, var(--gp-sidebar-accent) 7%, var(--gp-sidebar-surface)), + color-mix(in srgb, var(--gp-sidebar-surface) 97%, transparent) + ); +} + +.chat-item.thinking::after { + z-index: -1; + background: linear-gradient( + 105deg, + transparent 0%, + color-mix(in srgb, var(--gp-sidebar-accent-bright) 3%, transparent) 32%, + color-mix(in srgb, var(--gp-sidebar-accent-bright) 20%, transparent) 48%, + color-mix(in srgb, white 7%, transparent) 52%, + color-mix(in srgb, var(--gp-sidebar-accent-bright) 4%, transparent) 67%, + transparent 100% + ); + animation-duration: 1.85s; +} + +.thinking-dots span { + background: var(--gp-sidebar-accent-bright); + box-shadow: 0 0 8px color-mix(in srgb, var(--gp-sidebar-accent-bright) 46%, transparent); +} + +.thinking-phase-icon { + color: var(--gp-sidebar-accent-bright); + animation: gpWorkingPulse 1.35s ease-in-out infinite; +} + +.chat-item.streaming .chat-content::after { + width: 2px; + background: var(--gp-sidebar-accent-bright); + box-shadow: 0 0 7px color-mix(in srgb, var(--gp-sidebar-accent-bright) 55%, transparent); +} + +.role-avatar.user { + background: color-mix(in srgb, var(--gp-sidebar-accent) 18%, transparent); + color: var(--gp-sidebar-accent-bright); + border-color: color-mix(in srgb, var(--gp-sidebar-accent) 34%, var(--gp-sidebar-border)); +} + +.role-avatar.assistant { + background: color-mix(in srgb, var(--gp-sidebar-accent) 10%, transparent); + color: var(--gp-sidebar-accent-bright); + border-color: color-mix(in srgb, var(--gp-sidebar-accent) 28%, var(--gp-sidebar-border)); +} + +.chat-content pre, +.approval-diff, +.terminal-output { + border-radius: var(--gp-sidebar-radius-sm); +} + +.gp-inline-card { + margin-top: 12px; + padding: 12px; + border: 1px solid var(--gp-sidebar-border); + border-radius: var(--gp-sidebar-radius-md); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--gp-sidebar-surface-raised) 98%, var(--gp-sidebar-accent-faint)), + var(--gp-sidebar-surface) + ); + box-shadow: none; + animation: gpCardReveal 180ms ease both; +} + +.gp-task-summary { + margin-top: 0; + margin-bottom: 12px; +} + +.gp-task-summary .progress-track { + position: relative; + height: 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--vscode-foreground) 9%, transparent); + overflow: hidden; +} + +.gp-task-summary .progress-track::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, white 13%, transparent), + transparent + ); + transform: translateX(-100%); + animation: gpProgressTrackSweep 2.2s linear infinite; + pointer-events: none; +} + +.gp-task-summary .progress-bar { + position: relative; + min-width: 2px; + background: linear-gradient( + 90deg, + var(--gp-sidebar-accent), + var(--gp-sidebar-accent-bright) + ); + box-shadow: 0 0 10px color-mix(in srgb, var(--gp-sidebar-accent-bright) 38%, transparent); + transition: width 420ms cubic-bezier(0.2, 0.75, 0.25, 1); +} + +.gp-task-summary .progress-bar::after { + content: ""; + position: absolute; + top: -2px; + right: -1px; + width: 7px; + height: 10px; + border-radius: 999px; + background: var(--gp-sidebar-accent-bright); + box-shadow: + 0 0 5px var(--gp-sidebar-accent-bright), + 0 0 12px color-mix(in srgb, var(--gp-sidebar-accent-bright) 65%, transparent); + animation: gpProgressSpark 1.5s ease-in-out infinite; +} + +.plan-item { + position: relative; + overflow: hidden; + border-radius: var(--gp-sidebar-radius-sm); +} + +.plan-item.active { + border-color: color-mix(in srgb, var(--gp-sidebar-accent) 34%, var(--gp-sidebar-border)); + background: color-mix(in srgb, var(--gp-sidebar-surface) 92%, var(--gp-sidebar-accent-soft)); +} + +.plan-item.active::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + width: 38%; + left: -45%; + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--gp-sidebar-accent-bright) 17%, transparent), + transparent + ); + animation: gpActiveStepScan 1.9s ease-in-out infinite; + pointer-events: none; +} + +.plan-item.active .plan-marker { + border-color: color-mix(in srgb, var(--gp-sidebar-accent) 55%, var(--gp-sidebar-border)); + color: var(--gp-sidebar-accent-bright); + box-shadow: 0 0 10px color-mix(in srgb, var(--gp-sidebar-accent-bright) 22%, transparent); + animation: gpWorkingPulse 1.45s ease-in-out infinite; +} + +.activity-item.running .activity-icon { + color: var(--gp-sidebar-accent-bright); + animation: thinkingPulse 1.2s infinite ease-in-out; +} + +.approval-card:not(.hidden) { + animation: gpApprovalReveal 220ms cubic-bezier(0.18, 0.75, 0.25, 1) both; +} + +.results-bar:not(.hidden) .test-badge, +.results-bar:not(.hidden) .diag-badge { + animation: gpBadgePop 220ms ease both; +} + +/* Empty state ------------------------------------------------------------- */ + +.gp-empty-state { + min-height: 100%; + display: grid; + align-content: center; + justify-items: center; + gap: 16px; + padding: 24px 2px; + text-align: center; +} + +.gp-empty-copy { + display: grid; + justify-items: center; + gap: 7px; +} + +.gp-empty-state .empty-heading { + margin: 0; + color: var(--vscode-foreground); + font-size: 15px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.gp-empty-state .empty-subtext { + max-width: 285px; + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 12px; + line-height: 1.55; +} + +.gp-avatar-orbit { + position: relative; + width: 72px; + height: 72px; + display: grid; + place-items: center; +} + +.gp-avatar { + position: relative; + z-index: 2; + width: 48px; + height: 48px; + display: grid; + place-items: center; + border: 1px solid color-mix(in srgb, var(--gp-sidebar-accent) 68%, var(--gp-sidebar-border)); + border-radius: 999px; + background: + radial-gradient( + circle at 35% 30%, + color-mix(in srgb, var(--gp-sidebar-accent-bright) 18%, transparent), + transparent 48% + ), + color-mix(in srgb, var(--vscode-sideBar-background) 92%, black); + color: var(--gp-sidebar-accent-bright); + font-size: 14px; + font-weight: 800; + letter-spacing: 0.02em; + box-shadow: + 0 0 0 5px color-mix(in srgb, var(--gp-sidebar-accent) 5%, transparent), + 0 0 22px color-mix(in srgb, var(--gp-sidebar-accent-bright) 15%, transparent); + animation: gpAvatarBreathe 3.8s ease-in-out infinite; +} + +.gp-orbit-ring { + position: absolute; + inset: 5px; + border: 1px solid color-mix(in srgb, var(--gp-sidebar-accent) 33%, transparent); + border-radius: 999px; +} + +.gp-orbit-ring::before { + content: ""; + position: absolute; + top: -2px; + left: 50%; + width: 4px; + height: 4px; + border-radius: 999px; + background: var(--gp-sidebar-accent-bright); + box-shadow: 0 0 8px var(--gp-sidebar-accent-bright); +} + +.gp-orbit-ring-one { + animation: gpOrbitSpin 7s linear infinite; +} + +.gp-orbit-ring-two { + inset: 0; + border-style: dashed; + opacity: 0.45; + animation: gpOrbitSpinReverse 11s linear infinite; +} + +.gp-suggestion-grid { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; + margin: 0; +} + +.gp-suggestion-grid .suggestion-chip { + min-width: 0; + min-height: 42px; + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: center; + gap: 9px; + padding: 9px 11px; + border: 1px solid var(--gp-sidebar-border); + border-radius: var(--gp-sidebar-radius-md); + background: color-mix(in srgb, var(--gp-sidebar-surface) 96%, transparent); + color: var(--vscode-foreground); + font-size: 12px; + font-weight: 600; + text-align: left; + transform: translateY(0); + transition: + transform 140ms ease, + border-color 140ms ease, + background 140ms ease, + box-shadow 140ms ease; +} + +.gp-suggestion-grid .suggestion-chip:hover { + border-color: color-mix(in srgb, var(--gp-sidebar-accent) 45%, var(--gp-sidebar-border)); + background: color-mix(in srgb, var(--gp-sidebar-surface) 91%, var(--gp-sidebar-accent-soft)); + box-shadow: 0 5px 14px color-mix(in srgb, black 18%, transparent); + transform: translateY(-1px); +} + +.gp-suggestion-grid .suggestion-chip:active { + transform: translateY(0); +} + +.gp-suggestion-icon { + width: 24px; + height: 24px; + display: grid; + place-items: center; + color: var(--gp-sidebar-accent-bright); + font-family: var(--vscode-editor-font-family, monospace); + font-size: 15px; + font-weight: 700; +} + +@media (min-width: 385px) { + .gp-suggestion-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* Composer --------------------------------------------------------------- */ + +.gp-composer { + position: relative; + z-index: 5; + display: grid; + gap: 7px; + min-width: 0; + padding: 10px 12px 11px; + border-top: 1px solid var(--gp-sidebar-border); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--vscode-sideBar-background) 96%, transparent), + var(--vscode-sideBar-background) + ); + box-shadow: 0 -10px 24px color-mix(in srgb, black 10%, transparent); +} + +.gp-input-shell { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 7px; + padding: 8px; + border: 1px solid var(--vscode-input-border, var(--gp-sidebar-border)); + border-radius: var(--gp-sidebar-radius-md); + background: var(--vscode-input-background); + transition: + border-color 140ms ease, + box-shadow 140ms ease, + background 140ms ease; +} + +.gp-input-shell:focus-within { + border-color: color-mix(in srgb, var(--gp-sidebar-accent) 78%, var(--vscode-focusBorder)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--gp-sidebar-accent) 24%, transparent), + 0 0 16px color-mix(in srgb, var(--gp-sidebar-accent-bright) 7%, transparent); +} + +#chat-input { + width: 100%; + min-height: 42px; + max-height: 190px; + margin: 0; + padding: 3px 4px; + resize: none; + overflow-y: auto; + border: 0; + border-radius: 0; + outline: 0; + background: transparent; + color: var(--vscode-input-foreground); + font: inherit; + line-height: 1.5; +} + +#chat-input::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.gp-input-actions { + display: inline-flex; + align-items: center; + padding-bottom: 1px; +} + +#send-btn { + position: relative; + width: 30px; + height: 30px; + min-width: 30px; + display: grid; + place-items: center; + padding: 0; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--gp-sidebar-accent-bright) 58%, var(--gp-sidebar-accent)); + border-radius: var(--gp-sidebar-radius-sm); + background: linear-gradient(145deg, var(--gp-sidebar-accent), var(--gp-sidebar-accent-bright)); + color: #fff; + font-size: 12px; + font-weight: 800; + box-shadow: + 0 3px 9px color-mix(in srgb, var(--gp-sidebar-accent) 25%, transparent), + inset 0 1px 0 color-mix(in srgb, white 18%, transparent); + transition: + transform 120ms ease, + box-shadow 120ms ease, + filter 120ms ease; +} + +#send-btn::after { + content: ""; + position: absolute; + inset: -45%; + background: linear-gradient( + 115deg, + transparent 35%, + color-mix(in srgb, white 24%, transparent) 49%, + transparent 63% + ); + transform: translateX(-85%); + pointer-events: none; +} + +#send-btn:hover { + background: linear-gradient(145deg, var(--gp-sidebar-accent), var(--gp-sidebar-accent-bright)); + filter: brightness(1.06); + transform: translateY(-1px); + box-shadow: + 0 5px 14px color-mix(in srgb, var(--gp-sidebar-accent) 34%, transparent), + 0 0 13px color-mix(in srgb, var(--gp-sidebar-accent-bright) 18%, transparent); +} + +#send-btn:hover::after { + animation: gpButtonSheen 0.75s ease; +} + +#send-btn:active { + transform: translateY(0); +} + +#send-btn.stop-mode { + border-color: color-mix(in srgb, var(--danger) 55%, var(--gp-sidebar-border)); + background: color-mix(in srgb, var(--danger) 16%, var(--vscode-input-background)); + color: var(--danger); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--danger) 14%, transparent); + animation: gpStopPulse 1.5s ease-in-out infinite; +} + +body.gp-is-busy .gp-input-shell { + border-color: color-mix(in srgb, var(--gp-sidebar-accent) 48%, var(--gp-sidebar-border)); +} + +body.gp-is-busy #send-btn:not(.stop-mode) { + animation: gpSendPulse 1.5s ease-in-out infinite; +} + +.gp-send-icon { + display: block; + line-height: 1; +} + +.gp-compose-hint { + color: var(--vscode-descriptionForeground); + font-size: 10px; + line-height: 1.25; +} + +.gp-composer-footer { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.gp-task-status { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 7px; + overflow: hidden; + color: var(--vscode-descriptionForeground); + font-size: 11px; + white-space: nowrap; +} + +#compose-status { + overflow: hidden; + text-overflow: ellipsis; +} + +.gp-mode-selector { + display: inline-flex; + flex: 0 0 auto; + overflow: hidden; + border: 1px solid var(--gp-sidebar-border); + border-radius: var(--gp-sidebar-radius-sm); + background: color-mix(in srgb, var(--vscode-sideBar-background) 92%, black); +} + +.gp-mode-selector .mode-btn { + min-width: 39px; + padding: 5px 8px; + border: 0; + border-right: 1px solid var(--gp-sidebar-border); + border-radius: 0; + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: 10px; + line-height: 1; +} + +.gp-mode-selector .mode-btn:last-child { + border-right: 0; +} + +.gp-mode-selector .mode-btn:hover { + background: var(--vscode-list-hoverBackground); + color: var(--vscode-foreground); +} + +.gp-mode-selector .mode-btn.active, +.gp-mode-selector .mode-btn[aria-checked="true"] { + background: color-mix(in srgb, var(--gp-sidebar-accent) 16%, transparent); + color: var(--gp-sidebar-accent-bright); + box-shadow: inset 0 -2px 0 var(--gp-sidebar-accent); + animation: gpModeActivate 180ms ease both; +} + +.gp-composer .secondary-row { + display: flex; + gap: 7px; +} + +.gp-composer .secondary-row button { + flex: 1; +} + +/* Busy overlay remains a compatibility fallback. The animated in-chat */ +/* thinking bubble is the primary indicator and prevents content obstruction. */ +.busy-overlay { + display: none !important; +} + +/* Responsive density ----------------------------------------------------- */ + +@media (max-width: 330px) { + .gp-sidebar-header { + padding-inline: 10px; + } + + .gp-conversation, + .gp-composer { + padding-inline: 9px; + } + + .gp-repository-row { + align-items: flex-start; + } + + .gp-index-label { + display: none; + } + + .gp-mode-selector .mode-btn { + min-width: 34px; + padding-inline: 6px; + } +} + +@media (max-height: 620px) { + .gp-empty-state { + align-content: start; + padding-top: 16px; + } + + .gp-avatar-orbit { + width: 58px; + height: 58px; + } + + .gp-avatar { + width: 42px; + height: 42px; + } +} + +/* Motion ----------------------------------------------------------------- */ + +@keyframes gpConnectionGlow { + 0%, 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 28%, transparent); + } + 50% { + box-shadow: + 0 0 0 4px color-mix(in srgb, var(--success) 9%, transparent), + 0 0 9px color-mix(in srgb, var(--success) 32%, transparent); + } +} + +@keyframes gpStatusRing { + 0% { + opacity: 0.65; + transform: scale(0.55); + } + 70%, 100% { + opacity: 0; + transform: scale(1.35); + } +} + +@keyframes gpWorkingPulse { + 0%, 100% { + opacity: 0.58; + transform: scale(0.92); + } + 50% { + opacity: 1; + transform: scale(1.08); + } +} + +@keyframes gpAvatarBreathe { + 0%, 100% { + box-shadow: + 0 0 0 5px color-mix(in srgb, var(--gp-sidebar-accent) 5%, transparent), + 0 0 18px color-mix(in srgb, var(--gp-sidebar-accent-bright) 12%, transparent); + transform: scale(1); + } + 50% { + box-shadow: + 0 0 0 7px color-mix(in srgb, var(--gp-sidebar-accent) 7%, transparent), + 0 0 27px color-mix(in srgb, var(--gp-sidebar-accent-bright) 22%, transparent); + transform: scale(1.025); + } +} + +@keyframes gpOrbitSpin { + to { transform: rotate(360deg); } +} + +@keyframes gpOrbitSpinReverse { + to { transform: rotate(-360deg); } +} + +@keyframes gpProgressTrackSweep { + to { transform: translateX(100%); } +} + +@keyframes gpProgressSpark { + 0%, 100% { opacity: 0.55; transform: scale(0.8); } + 50% { opacity: 1; transform: scale(1.08); } +} + +@keyframes gpActiveStepScan { + 0% { left: -45%; opacity: 0; } + 18% { opacity: 1; } + 78% { opacity: 0.85; } + 100% { left: 110%; opacity: 0; } +} + +@keyframes gpCardReveal { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes gpApprovalReveal { + from { opacity: 0; transform: translateY(8px) scale(0.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes gpBadgePop { + 0% { opacity: 0; transform: scale(0.88); } + 65% { opacity: 1; transform: scale(1.04); } + 100% { opacity: 1; transform: scale(1); } +} + +@keyframes gpButtonSheen { + to { transform: translateX(85%); } +} + +@keyframes gpSendPulse { + 0%, 100% { + box-shadow: + 0 3px 9px color-mix(in srgb, var(--gp-sidebar-accent) 25%, transparent), + 0 0 0 0 color-mix(in srgb, var(--gp-sidebar-accent-bright) 0%, transparent); + } + 50% { + box-shadow: + 0 4px 12px color-mix(in srgb, var(--gp-sidebar-accent) 34%, transparent), + 0 0 0 4px color-mix(in srgb, var(--gp-sidebar-accent-bright) 8%, transparent); + } +} + +@keyframes gpStopPulse { + 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--danger) 0%, transparent); } + 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 8%, transparent); } +} + +@keyframes gpModeActivate { + from { opacity: 0.65; } + to { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .gp-connection-dot, + .gp-status-dot, + .gp-avatar, + .gp-orbit-ring, + .gp-task-summary .progress-track::after, + .gp-task-summary .progress-bar::after, + .plan-item.active::after, + .plan-item.active .plan-marker, + .thinking-phase-icon, + .activity-item.running .activity-icon, + #send-btn, + .gp-mode-selector .mode-btn { + animation: none !important; + } +} diff --git a/extensions/vscode/src/ui/webview/gitpilotWorkspace.js b/extensions/vscode/src/ui/webview/gitpilotWorkspace.js new file mode 100644 index 0000000..ebf3725 --- /dev/null +++ b/extensions/vscode/src/ui/webview/gitpilotWorkspace.js @@ -0,0 +1,1221 @@ +const vscode = acquireVsCodeApi(); + + let currentState = null; + let lastError = null; + let busyCount = 0; + let lastRenderedSignature = ""; + let dynamicHandlerAbortController = null; + let thinkingStartedAt = 0; + let thinkingTimerId = null; + + const byId = (id) => document.getElementById(id); + + const esc = (value) => + String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + const post = (msg) => vscode.postMessage(msg); + const nowIso = () => new Date().toISOString(); + + const formatTime = (iso) => { + try { + const date = iso ? new Date(iso) : new Date(); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } catch { + return ""; + } + }; + + const formatElapsed = (ms) => { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + }; + + const statusLabel = (status) => ({ + idle: "Idle", + planning: "Planning", + generating: "Generating", + reviewing: "Reviewing", + ready_to_apply: "Ready to apply", + applying: "Applying", + done: "Done", + failed: "Failed" + }[status] || String(status || "idle")); + + const phaseMap = { + planning: { icon: "●", label: "Analyzing your request..." }, + generating: { icon: "✎", label: "Writing response..." }, + reviewing: { icon: "🔍", label: "Reviewing changes..." }, + applying: { icon: "⚡", label: "Applying changes..." }, + }; + + const getPhaseLabel = () => { + const status = ((currentState || {}).activeTask || {}).status; + return (phaseMap[status] || {}).label || "GitPilot is thinking..."; + }; + + const getPhaseIcon = () => { + const status = ((currentState || {}).activeTask || {}).status; + return (phaseMap[status] || {}).icon || "●"; + }; + + const pillClassForStatus = (status) => { + if (status === "failed") return "pill pill-danger"; + if (status === "ready_to_apply" || status === "done") return "pill pill-success"; + if (status === "planning" || status === "generating" || status === "reviewing" || status === "applying") return "pill pill-accent"; + return "pill"; + }; + + const normalizeAssistantText = (text) => { + const value = String(text || "").trim(); + if (!value) return "GitPilot is ready for your next request."; + + return esc(value) + .replace(/^###\s*(.+)$/gm, "

$1

") + .replace(/^##\s*(.+)$/gm, "

$1

") + .replace(/\*\*(.*?)\*\*/g, "$1"); + }; + + const getChatMessages = (state) => { + const messages = (((state || {}).chat || {}).messages || []).slice(); + if (messages.length > 0) return messages; + + return [{ + id: "assistant:default", + role: "assistant", + content: "What would you like GitPilot to do?", + createdAt: nowIso() + }]; + }; + + const taskIsActive = (state) => { + if (!state) return false; + if (state.ui && state.ui.mode === "idle") return false; + + const task = state.activeTask || {}; + return Boolean( + task.title || + (task.plan && task.plan.steps && task.plan.steps.length) || + (task.filesInScope && task.filesInScope.length) || + (task.changedFiles && task.changedFiles.length) || + (task.status && task.status !== "idle") + ); + }; + + const startThinkingTimer = () => { + if (!thinkingStartedAt) thinkingStartedAt = Date.now(); + stopThinkingTimer(); + thinkingTimerId = window.setInterval(() => { + const elapsedNode = byId("chat-thinking-elapsed"); + if (elapsedNode && thinkingStartedAt) { + elapsedNode.textContent = formatElapsed(Date.now() - thinkingStartedAt); + } + }, 1000); + }; + + const stopThinkingTimer = () => { + if (thinkingTimerId) { + window.clearInterval(thinkingTimerId); + thinkingTimerId = null; + } + }; + + const pushOptimisticUserMessage = (text) => { + if (!currentState) currentState = {}; + if (!currentState.chat) currentState.chat = {}; + if (!Array.isArray(currentState.chat.messages)) currentState.chat.messages = []; + + currentState.chat.messages = [ + ...currentState.chat.messages, + { + id: "user:optimistic:" + Date.now(), + role: "user", + content: text, + createdAt: nowIso() + } + ]; + + render(currentState); + }; + + const collapseThinkingBubble = (callback) => { + const existing = byId("chat-thinking-item"); + if (!existing) { callback(); return; } + existing.classList.add("collapsing"); + existing.addEventListener("animationend", () => { + existing.remove(); + callback(); + }, { once: true }); + setTimeout(() => { if (existing.parentNode) { existing.remove(); callback(); } }, 250); + }; + + const setLoadingIndicator = (visible, text) => { + const list = byId("chat-list"); + if (!list) return; + + const existing = byId("chat-thinking-item"); + + if (!visible) { + if (existing) { + collapseThinkingBubble(() => {}); + } + thinkingStartedAt = 0; + stopThinkingTimer(); + return; + } + + if (!thinkingStartedAt) { + thinkingStartedAt = Date.now(); + } + + const phaseLabel = text || getPhaseLabel(); + const phaseIcon = getPhaseIcon(); + + if (existing) { + const label = existing.querySelector(".thinking-label"); + const elapsed = existing.querySelector("#chat-thinking-elapsed"); + const icon = existing.querySelector(".thinking-phase-icon"); + + if (label) label.textContent = phaseLabel; + if (elapsed) elapsed.textContent = formatElapsed(Date.now() - thinkingStartedAt); + if (icon) icon.innerHTML = phaseIcon; + + startThinkingTimer(); + + requestAnimationFrame(() => { + list.scrollTop = list.scrollHeight; + }); + return; + } + + const item = document.createElement("li"); + item.id = "chat-thinking-item"; + item.className = "chat-item assistant thinking"; + item.innerHTML = ` +
+
+ G + GitPilot +
+
${formatElapsed(Date.now() - thinkingStartedAt)}
+
+
+
+
+ + + ${esc(phaseLabel)} +
+
+
+ `; + + list.appendChild(item); + startThinkingTimer(); + + requestAnimationFrame(() => { + list.scrollTop = list.scrollHeight; + }); + }; + + const pushAssistantMessage = (payload) => { + if (!payload) return; + if (!currentState) currentState = {}; + if (!currentState.chat) currentState.chat = {}; + if (!Array.isArray(currentState.chat.messages)) currentState.chat.messages = []; + + currentState.chat.messages = [ + ...currentState.chat.messages, + { + id: payload.id || ("assistant:" + Date.now()), + role: payload.role || "assistant", + content: payload.content || "", + createdAt: payload.createdAt || nowIso() + } + ]; + + const activeTask = currentState.activeTask || {}; + if (payload.plan && (!activeTask.plan || !activeTask.plan.steps || !activeTask.plan.steps.length)) { + currentState.activeTask = { + ...activeTask, + plan: payload.plan, + status: activeTask.status || "planning", + title: activeTask.title || "Task in progress" + }; + } + + render(currentState); + + requestAnimationFrame(() => { + const items = document.querySelectorAll(".chat-item.assistant:not(.thinking)"); + const last = items[items.length - 1]; + if (!last) return; + const taskStatus = (currentState.activeTask || {}).status; + if (taskStatus === "done") { + last.classList.add("success-flash"); + last.addEventListener("animationend", () => last.classList.remove("success-flash"), { once: true }); + } else if (taskStatus === "failed") { + last.classList.add("error-flash"); + last.addEventListener("animationend", () => last.classList.remove("error-flash"), { once: true }); + } + }); + }; + + const setActionButtonsDisabled = (visible) => { + ["send-btn", "apply-btn", "revert-btn"].forEach((id) => { + const button = byId(id); + if (button) button.disabled = visible; + }); + }; + + const updateSendButton = (busy) => { + const btn = byId("send-btn"); + if (!btn) return; + + btn.classList.toggle("stop-mode", busy); + btn.disabled = false; + btn.setAttribute("aria-label", busy ? "Stop generation" : "Send message"); + btn.setAttribute("title", busy ? "Stop generation" : "Send message"); + btn.innerHTML = ``; + }; + + const setBusy = (busy, text) => { + busyCount = Math.max(0, busy ? busyCount + 1 : busyCount - 1); + + const visible = busyCount > 0; + const overlay = byId("busy-overlay"); + const composeStatus = byId("compose-status"); + + overlay.classList.toggle("visible", visible); + overlay.setAttribute("aria-hidden", visible ? "false" : "true"); + document.body.classList.toggle("gp-is-busy", visible); + + if (text) byId("busy-text").textContent = text; + composeStatus.textContent = visible ? (text || "Working…") : "Ready"; + + const taskStatusDot = byId("task-status-dot"); + if (taskStatusDot) { + taskStatusDot.className = visible ? "gp-status-dot is-working" : "gp-status-dot is-ready"; + } + + setLoadingIndicator(visible, text || getPhaseLabel()); + setActionButtonsDisabled(visible); + updateSendButton(visible); + }; + + const clearBusy = () => { + busyCount = 0; + byId("busy-overlay").classList.remove("visible"); + byId("busy-overlay").setAttribute("aria-hidden", "true"); + document.body.classList.remove("gp-is-busy"); + byId("compose-status").textContent = "Ready"; + + const taskStatusDot = byId("task-status-dot"); + if (taskStatusDot) taskStatusDot.className = "gp-status-dot is-ready"; + + setLoadingIndicator(false); + setActionButtonsDisabled(false); + updateSendButton(false); + }; + + const renderError = (state) => { + const banner = byId("error-banner"); + const copy = byId("error-copy"); + + const notice = lastError || ( + state && state.ui && state.ui.notice + ? { title: "Notice", message: state.ui.notice } + : undefined + ); + + if (!notice) { + banner.classList.add("hidden"); + banner.classList.remove("danger"); + copy.textContent = ""; + return; + } + + banner.classList.remove("hidden"); + banner.classList.toggle("danger", Boolean(lastError)); + copy.textContent = String(notice.title || "Notice") + ": " + String(notice.message || ""); + }; + + const renderHeader = (state) => { + const provider = state.provider || {}; + const project = state.projectContextSummary || {}; + const workspace = state.workspace || {}; + const server = state.server || {}; + const task = state.activeTask || {}; + + const repoName = project.repoName || (workspace.git && workspace.git.repoName) || workspace.folderName || "No repo"; + const branch = project.branch || (workspace.git && workspace.git.branch) || (state.session && state.session.branch) || "No branch"; + const providerName = provider.providerName || "Provider not set"; + const model = provider.model || "No model"; + const workflow = (state.workflow && state.workflow.selectedMode) || "auto"; + const connection = server.connected + ? (provider.health === "error" ? "Degraded" : "Connected") + : "Disconnected"; + + byId("provider-line").textContent = providerName + " · " + connection + " · " + model; + byId("repo-line").textContent = "Repo: " + repoName + " · Branch: " + branch; + + const connectionDot = byId("connection-dot"); + if (connectionDot) { + const connectionClass = connection === "Connected" + ? "is-connected" + : connection === "Degraded" + ? "is-degraded" + : "is-disconnected"; + connectionDot.className = "gp-connection-dot " + connectionClass; + connectionDot.setAttribute("aria-label", connection); + connectionDot.setAttribute("title", connection); + } + + // workflow-pill removed from UI for cleaner layout + + byId("connection-pill").textContent = connection; + byId("connection-pill").className = + connection === "Connected" + ? "pill pill-success" + : connection === "Degraded" + ? "pill pill-warning" + : "pill pill-danger"; + + const taskActive = task.status && task.status !== "idle"; + byId("task-pill").textContent = taskActive ? statusLabel(task.status) : "Ready"; + byId("task-pill").className = taskActive ? "pill pill-accent" : "pill"; + + // Sync execution mode selector with state + const execMode = state.executionMode || "ask"; + document.querySelectorAll(".mode-btn").forEach((btn) => { + const active = btn.dataset.mode === execMode; + btn.classList.toggle("active", active); + btn.setAttribute("aria-checked", String(active)); + }); + }; + + const renderOverview = (state) => { + const project = state.projectContextSummary || {}; + const recentFiles = (project.recentFiles || project.keyFiles || []).slice(0, 6); + const recentSection = byId("idle-recent-section"); + + if (!recentFiles.length) { + recentSection.classList.add("hidden"); + byId("recent-files-list").innerHTML = ""; + return; + } + + recentSection.classList.remove("hidden"); + byId("recent-files-list").innerHTML = recentFiles.map((file) => ` +
  • +
    +
    +
    ${esc(file)}
    +
    Recently referenced in repository context
    +
    +
    + +
    +
    +
  • + `).join(""); + }; + + const renderSummary = (state) => { + const task = state.activeTask || {}; + const steps = ((task.plan || {}).steps || []); + const totalSteps = steps.length; + const completedCount = steps.filter((step) => + ["applied", "ready", "done"].includes(step.status) + ).length; + const ratio = totalSteps > 0 ? Math.max(0, Math.min(1, completedCount / totalSteps)) : 0; + const activeStepIndex = steps.findIndex((step) => !["applied", "ready", "done"].includes(step.status)); + const currentStep = totalSteps === 0 ? 0 : (activeStepIndex >= 0 ? activeStepIndex + 1 : totalSteps); + + // ── Only show the Task Status card when there is real progress + // to display (plan steps with a progress ratio). During the initial + // "planning" / "generating" phases without steps, the inline + // thinking bubble in chat is the only status indicator — showing + // the Task Status card too creates double-stacked UI that pushes + // the chat down and overlaps visually. + const hasProgress = totalSteps > 0; + const isTerminalWithInfo = ["done", "failed", "ready_to_apply"].includes(task.status) && (task.title || task.summary); + const showCard = hasProgress || isTerminalWithInfo; + byId("assistant-summary-section").classList.toggle("hidden", !showCard); + + if (!showCard) return; + + byId("task-title").textContent = task.title || ""; + byId("task-status-pill").textContent = statusLabel(task.status); + byId("task-status-pill").className = pillClassForStatus(task.status); + const stepPill = byId("task-step-pill"); + stepPill.textContent = totalSteps > 0 ? ("Step " + Math.max(1, currentStep) + "/" + totalSteps) : ""; + stepPill.classList.toggle("hidden", totalSteps === 0); + + byId("task-summary").textContent = + task.status === "ready_to_apply" ? "Changes ready to review." + : task.status === "done" ? (task.summary || "Done.") + : task.status === "failed" ? "See chat for details." + : ""; + + const progressSection = byId("task-progress-bar").parentElement.parentElement; + if (totalSteps > 0) { + progressSection.classList.remove("hidden"); + byId("task-progress-bar").style.width = Math.round(ratio * 100) + "%"; + byId("task-progress-label").textContent = completedCount + "/" + totalSteps + " steps"; + } else { + progressSection.classList.add("hidden"); + } + }; + + const renderPlan = (state) => { + const section = byId("plan-section"); + const list = byId("plan-list"); + const steps = (((state.activeTask || {}).plan || {}).steps || []); + + section.classList.toggle("hidden", !steps.length); + + list.innerHTML = steps.length + ? steps.map((step, index) => { + let cssClass = "pending"; + if (["applied", "ready", "done"].includes(step.status)) cssClass = "done"; + else if (step.status === "failed") cssClass = "failed"; + else if (index === 0 || step.status === "pending" || step.status === "planning") cssClass = "active"; + + const marker = + cssClass === "done" ? "✓" : + cssClass === "failed" ? "!" : + cssClass === "active" ? "→" : "•"; + + return ` +
  • +
    ${marker}
    +
    +
    ${esc(step.title || step.action || ("Step " + (index + 1)))}
    +
    ${esc(step.description || step.file || "Planned task step")}
    +
    +
  • + `; + }).join("") + : ""; + + // Show the "Approve & Execute" bar when plan steps exist and + // the task has not started executing yet. Once executing/done, + // hide it — the user already approved or the agent auto-ran. + const approvalBar = byId("plan-approval-bar"); + if (approvalBar) { + const taskStatus = ((state.activeTask) || {}).status || "idle"; + const planReady = steps.length > 0 && ["planning", "ready_to_apply"].includes(taskStatus); + approvalBar.classList.toggle("hidden", !planReady); + } + }; + + const renderScope = (state) => { + const section = byId("scope-section"); + const list = byId("scope-list"); + const files = ((state.activeTask || {}).filesInScope || []); + + section.classList.toggle("hidden", !files.length); + + list.innerHTML = files.map((item) => ` +
  • +
    +
    +
    ${esc(item.path)}
    +
    ${esc(item.reason || "In scope for the current task")}
    +
    +
    + + +
    +
    +
  • + `).join(""); + }; + + const renderChanges = (state) => { + const section = byId("changes-section"); + const list = byId("changes-list"); + const changes = ((state.activeTask || {}).changedFiles || []); + + section.classList.toggle("hidden", !changes.length); + + list.innerHTML = changes.map((item) => ` +
  • +
    +
    +
    ${esc((item.kind || "M") + " " + item.path)}
    +
    ${esc(item.summary || item.reason || item.status || "Proposed change")}
    +
    +
    + ${esc(String(item.status || "pending"))} + + +
    +
    +
  • + `).join(""); + }; + + const roleAvatarLetter = (role) => + role === "user" ? "U" : role === "assistant" ? "G" : "S"; + + const roleName = (role) => + role === "assistant" ? "GitPilot" : role === "user" ? "You" : "System"; + + const injectCopyButtons = () => { + document.querySelectorAll(".chat-content pre").forEach((pre) => { + if (pre.querySelector(".code-copy-btn")) return; + const btn = document.createElement("button"); + btn.className = "code-copy-btn"; + btn.textContent = "Copy"; + btn.type = "button"; + btn.addEventListener("click", () => { + const code = pre.querySelector("code"); + const text = code ? code.textContent : pre.textContent; + navigator.clipboard.writeText(text || "").then(() => { + btn.textContent = "\u2713 Copied"; + btn.classList.add("copied"); + setTimeout(() => { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1800); + }); + }); + pre.appendChild(btn); + }); + }; + + const renderChat = (state) => { + const list = byId("chat-list"); + const emptyState = byId("chat-empty-state"); + const messages = getChatMessages(state); + const thinkingVisible = busyCount > 0; + const isStreaming = Boolean(streamingItemId); + + const hasRealMessages = (((state || {}).chat || {}).messages || []).length > 0; + + if (!hasRealMessages && !thinkingVisible && !isStreaming) { + list.classList.add("hidden"); + list.innerHTML = ""; + if (emptyState) emptyState.classList.remove("hidden"); + return; + } + + if (emptyState) emptyState.classList.add("hidden"); + list.classList.remove("hidden"); + + // ── Key fix: preserve in-flight streaming and thinking DOM nodes. + // The old code did `list.innerHTML = ...` which destroyed the + // streaming
  • created by handleStreamChunk() and the thinking + // bubble created by setLoadingIndicator(). Those nodes aren't in + // state.chat.messages yet, so the rerender wiped them. + // + // New approach: detach transient nodes, rebuild the state-driven + // messages, then re-attach the transient nodes at the end. + const streamingNode = streamingItemId ? document.getElementById(streamingItemId) : null; + const thinkingNode = byId("chat-thinking-item"); + + // Detach transient nodes before innerHTML wipe + if (streamingNode) streamingNode.remove(); + if (thinkingNode) thinkingNode.remove(); + + list.innerHTML = messages.map((msg) => ` +
  • +
    +
    + ${roleAvatarLetter(msg.role)} + ${esc(roleName(msg.role))} +
    +
    ${esc(formatTime(msg.createdAt))}
    +
    +
    ${msg.role === "assistant" ? normalizeAssistantText(msg.content) : esc(msg.content)}
    +
  • + `).join(""); + + // Re-attach transient nodes after the state-driven messages + if (streamingNode) list.appendChild(streamingNode); + + if (thinkingVisible || isStreaming) { + if (thinkingNode) { + list.appendChild(thinkingNode); + } else { + setLoadingIndicator(true, getPhaseLabel()); + } + } + + requestAnimationFrame(() => { + list.scrollTop = list.scrollHeight; + injectCopyButtons(); + }); + }; + + const renderContextualActions = (state) => { + const task = state.activeTask || {}; + const changes = task.changedFiles || []; + const hasPatch = task.status === "ready_to_apply" || changes.length > 0; + const showSecondaryRow = hasPatch; + + byId("secondary-actions-row").classList.toggle("hidden", !showSecondaryRow); + byId("apply-btn").classList.toggle("hidden", !hasPatch); + byId("revert-btn").classList.toggle("hidden", !hasPatch); + + const showTaskControls = taskIsActive(state) || hasPatch; + byId("actions-section").classList.toggle("hidden", !showTaskControls); + }; + + const bindDynamicEvents = () => { + if (dynamicHandlerAbortController) { + dynamicHandlerAbortController.abort(); + } + + dynamicHandlerAbortController = new AbortController(); + const signal = dynamicHandlerAbortController.signal; + + document.querySelectorAll("[data-open-file]").forEach((node) => { + node.addEventListener("click", () => { + post({ type: "OPEN_CHANGED_FILE", payload: { path: node.getAttribute("data-open-file") } }); + }, { signal }); + }); + + document.querySelectorAll("[data-open-diff]").forEach((node) => { + node.addEventListener("click", () => { + post({ type: "OPEN_CHANGED_DIFF", payload: { path: node.getAttribute("data-open-diff") } }); + }, { signal }); + }); + + document.querySelectorAll("[data-reveal-file]").forEach((node) => { + node.addEventListener("click", () => { + post({ type: "REVEAL_FILE", payload: { path: node.getAttribute("data-reveal-file") } }); + }, { signal }); + }); + + document.querySelectorAll("[data-quick-action]").forEach((node) => { + node.addEventListener("click", () => { + const action = node.getAttribute("data-quick-action"); + if (!action) return; + setBusy(true, "Running " + action.replace(/_/g, " ") + "…"); + post({ type: "RUN_QUICK_ACTION", payload: { action } }); + }, { signal }); + }); + + document.querySelectorAll("[data-suggestion]").forEach((node) => { + node.addEventListener("click", () => { + const text = node.getAttribute("data-suggestion"); + if (text) sendSuggestion(text); + }, { signal }); + }); + }; + + const createRenderSignature = (state) => JSON.stringify({ + provider: state && state.provider, + server: state && state.server, + workflow: state && state.workflow, + projectContextSummary: state && state.projectContextSummary, + activeTask: state && state.activeTask, + chat: ((state || {}).chat || {}).messages || [], + ui: state && state.ui, + lastError, + busyCount + }); + + const render = (state) => { + const signature = createRenderSignature(state); + if (signature === lastRenderedSignature) return; + lastRenderedSignature = signature; + + currentState = state || {}; + + renderHeader(currentState); + renderError(currentState); + renderOverview(currentState); + renderSummary(currentState); + renderPlan(currentState); + renderScope(currentState); + renderChanges(currentState); + renderChat(currentState); + renderContextualActions(currentState); + bindDynamicEvents(); + }; + + const sendChat = () => { + const btn = byId("send-btn"); + + if (btn && btn.classList.contains("stop-mode")) { + post({ type: "CANCEL_TASK" }); + clearBusy(); + return; + } + + const input = byId("chat-input"); + const text = input.value.trim(); + if (!text) return; + + input.value = ""; + input.style.height = "auto"; + pushOptimisticUserMessage(text); + setBusy(true, getPhaseLabel()); + post({ type: "SEND_CHAT", payload: { text } }); + }; + + const sendSuggestion = (text) => { + if (!text) return; + const input = byId("chat-input"); + if (input) { + input.value = ""; + input.style.height = "auto"; + } + pushOptimisticUserMessage(text); + setBusy(true, getPhaseLabel()); + post({ type: "SEND_CHAT", payload: { text } }); + }; + + const requestRefreshContext = () => { + setBusy(true, "Refreshing repository context…"); + post({ type: "REFRESH_PROJECT_CONTEXT" }); + }; + + const handleContextualAction = () => { + const action = byId("contextual-btn").dataset.action; + + if (action === "apply") { + setBusy(true, "Applying proposed changes…"); + post({ type: "APPLY_PROPOSED_CHANGES" }); + return; + } + + if (action === "retry") { + const messages = getChatMessages(currentState); + const latestUser = [...messages].reverse().find((msg) => msg.role === "user"); + if (!latestUser) { + requestRefreshContext(); + return; + } + setBusy(true, "Retrying request…"); + post({ type: "SEND_CHAT", payload: { text: latestUser.content } }); + return; + } + + requestRefreshContext(); + }; + + // ── V2 event handlers ── + + const handleToolActivity = (payload) => { + const feed = byId("tool-activity-feed"); + const list = byId("activity-list"); + if (!feed || !list) return; + + feed.classList.remove("hidden"); + + let item = document.getElementById("activity-" + payload.id); + if (!item) { + item = document.createElement("li"); + item.id = "activity-" + payload.id; + item.className = "activity-item running"; + item.innerHTML = ` + + ${esc(payload.name)} + running + `; + list.appendChild(item); + if (list.children.length > 8) list.removeChild(list.firstChild); + } + + item.className = "activity-item " + esc(payload.status); + const statusEl = item.querySelector(".activity-status"); + if (statusEl) statusEl.textContent = payload.status; + + const iconEl = item.querySelector(".activity-icon"); + if (iconEl) { + iconEl.innerHTML = payload.status === "completed" ? "✓" + : payload.status === "failed" ? "✗" : "●"; + } + }; + + let pendingApprovalId = null; + + const showApprovalCard = (payload) => { + const card = byId("approval-card"); + if (!card) return; + + pendingApprovalId = payload.id; + byId("approval-title").textContent = "GitPilot wants to " + String(payload.tool || "use a tool").replace(/_/g, " "); + byId("approval-summary").textContent = payload.summary || ""; + + const riskEl = byId("approval-risk"); + riskEl.textContent = payload.riskLevel || "medium"; + riskEl.className = "pill approval-risk risk-" + (payload.riskLevel || "medium"); + + const diffEl = byId("approval-diff"); + if (payload.diffPreview) { + diffEl.textContent = payload.diffPreview; + diffEl.classList.remove("hidden"); + } else { + diffEl.classList.add("hidden"); + } + + card.classList.remove("hidden"); + }; + + const resolveApproval = (approved, scope) => { + if (!pendingApprovalId) return; + post({ + type: "TOOL_APPROVAL_RESPONSE", + payload: { id: pendingApprovalId, approved, scope: scope || "once" }, + }); + pendingApprovalId = null; + const card = byId("approval-card"); + if (card) card.classList.add("hidden"); + }; + + const handlePlanStepUpdate = (payload) => { + const list = byId("plan-list"); + if (!list) return; + + const items = list.querySelectorAll(".plan-item"); + const item = items[payload.stepIndex]; + if (!item) return; + + const marker = item.querySelector(".plan-marker"); + if (!marker) return; + + if (payload.status === "started" || payload.status === "running" || payload.status === "pending") { + item.className = "row plan-item active"; + marker.textContent = "\u2192"; + } else if (payload.status === "completed" || payload.status === "applied") { + item.className = "row plan-item done"; + marker.textContent = "\u2713"; + } else if (payload.status === "failed") { + item.className = "row plan-item failed"; + marker.textContent = "!"; + } + }; + + const handleTerminalOutput = (payload) => { + const panel = byId("terminal-panel"); + const output = byId("terminal-output"); + if (!panel || !output) return; + + panel.classList.remove("hidden"); + output.textContent += payload.text || ""; + + requestAnimationFrame(() => { + output.scrollTop = output.scrollHeight; + }); + }; + + const handleDiagnostics = (payload) => { + const bar = byId("results-bar"); + if (!bar) return; + bar.classList.remove("hidden"); + + const errBadge = byId("diag-errors-badge"); + const warnBadge = byId("diag-warnings-badge"); + + if (payload.errors > 0 && errBadge) { + errBadge.textContent = payload.errors + " error" + (payload.errors !== 1 ? "s" : ""); + errBadge.classList.remove("hidden"); + } + if (payload.warnings > 0 && warnBadge) { + warnBadge.textContent = payload.warnings + " warning" + (payload.warnings !== 1 ? "s" : ""); + warnBadge.classList.remove("hidden"); + } + }; + + const handleTestResult = (payload) => { + const bar = byId("results-bar"); + if (!bar) return; + bar.classList.remove("hidden"); + + const passed = byId("test-passed-badge"); + const failed = byId("test-failed-badge"); + const skipped = byId("test-skipped-badge"); + + if (payload.passed > 0 && passed) { + passed.textContent = payload.passed + " passed"; + passed.classList.remove("hidden"); + } + if (payload.failed > 0 && failed) { + failed.textContent = payload.failed + " failed"; + failed.classList.remove("hidden"); + } + if (payload.skipped > 0 && skipped) { + skipped.textContent = payload.skipped + " skipped"; + skipped.classList.remove("hidden"); + } + }; + + const bindStaticEvents = () => { + byId("send-btn").addEventListener("click", sendChat); + + byId("chat-input").addEventListener("input", () => { + const input = byId("chat-input"); + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 200) + "px"; + }); + + byId("chat-input").addEventListener("keydown", (event) => { + if ((event.key === "Enter" && !event.shiftKey) || ((event.ctrlKey || event.metaKey) && event.key === "Enter")) { + event.preventDefault(); + sendChat(); + } + if (event.key === "Escape") { + event.preventDefault(); + post({ type: "CANCEL_TASK" }); + clearBusy(); + } + }); + + byId("setup-context-btn").addEventListener("click", requestRefreshContext); + byId("refresh-context-card").addEventListener("click", requestRefreshContext); + + byId("provider-setup-btn").addEventListener("click", () => { + post({ type: "OPEN_PROVIDER_SETUP" }); + }); + + byId("idle-setup-btn").addEventListener("click", () => { + post({ type: "OPEN_SETUP_WIZARD" }); + }); + + // ── New Chat button: clears chat and starts a fresh session ── + byId("open-workspace-btn")?.addEventListener("click", () => { + post({ type: "OPEN_WORKSPACE" }); + }); + + byId("new-chat-btn").addEventListener("click", () => { + // Clear local webview state immediately for instant feedback + const list = byId("chat-list"); + if (list) list.innerHTML = ""; + const emptyState = byId("chat-empty-state"); + if (emptyState) emptyState.classList.remove("hidden"); + streamingItemId = null; + clearBusy(); + // Tell the extension host to create a fresh session + post({ type: "NEW_SESSION" }); + }); + + // ── Plan approval: user must click before execution proceeds ── + byId("plan-approve-btn").addEventListener("click", () => { + byId("plan-approval-bar").classList.add("hidden"); + setBusy(true, "Executing approved plan…"); + post({ type: "APPROVE_PLAN" }); + }); + + byId("plan-reject-btn").addEventListener("click", () => { + byId("plan-approval-bar").classList.add("hidden"); + clearBusy(); + post({ type: "REJECT_PLAN" }); + }); + + byId("apply-btn").addEventListener("click", () => { + setBusy(true, "Applying proposed changes…"); + post({ type: "APPLY_PROPOSED_CHANGES" }); + }); + + byId("revert-btn").addEventListener("click", () => { + setBusy(true, "Reverting proposed changes…"); + post({ type: "REVERT_PROPOSED_CHANGES" }); + }); + + byId("replan-btn").addEventListener("click", () => { + setBusy(true, "Regenerating plan…"); + post({ type: "REGENERATE_TASK_PLAN" }); + }); + + byId("setup-btn").addEventListener("click", () => { + post({ type: "OPEN_SETUP_WIZARD" }); + }); + + byId("settings-header-btn").addEventListener("click", () => { + post({ type: "OPEN_SETTINGS" }); + }); + + // V2 approval card buttons + byId("approval-allow")?.addEventListener("click", () => resolveApproval(true, "once")); + byId("approval-allow-session")?.addEventListener("click", () => resolveApproval(true, "session")); + byId("approval-deny")?.addEventListener("click", () => resolveApproval(false)); + + // Terminal close + byId("terminal-close")?.addEventListener("click", () => { + const panel = byId("terminal-panel"); + if (panel) panel.classList.add("hidden"); + }); + + // ── Execution mode selector ── + document.querySelectorAll(".mode-btn").forEach((btn) => { + btn.addEventListener("click", () => { + document.querySelectorAll(".mode-btn").forEach((b) => { + b.classList.remove("active"); + b.setAttribute("aria-checked", "false"); + }); + btn.classList.add("active"); + btn.setAttribute("aria-checked", "true"); + post({ type: "SET_EXECUTION_MODE", payload: { mode: btn.dataset.mode } }); + }); + }); + }; + + let streamingItemId = null; + + const handleStreamChunk = (payload) => { + if (!payload || !payload.content) return; + const list = byId("chat-list"); + const emptyState = byId("chat-empty-state"); + if (emptyState) emptyState.classList.add("hidden"); + list.classList.remove("hidden"); + + const existing = byId("chat-thinking-item"); + if (existing) { existing.remove(); stopThinkingTimer(); } + + let item = streamingItemId ? document.getElementById(streamingItemId) : null; + + if (!item) { + streamingItemId = "streaming-" + Date.now(); + item = document.createElement("li"); + item.id = streamingItemId; + item.className = "chat-item assistant streaming"; + item.innerHTML = ` +
    +
    + G + GitPilot +
    +
    ${esc(formatTime(nowIso()))}
    +
    +
    + `; + list.appendChild(item); + } + + const content = item.querySelector(".chat-content"); + if (content) { + content.innerHTML = normalizeAssistantText( + (content.getAttribute("data-raw") || "") + payload.content + ); + content.setAttribute("data-raw", + (content.getAttribute("data-raw") || "") + payload.content + ); + } + + requestAnimationFrame(() => { + list.scrollTop = list.scrollHeight; + injectCopyButtons(); + }); + }; + + const finalizeStream = (payload) => { + const item = streamingItemId ? document.getElementById(streamingItemId) : null; + if (item) { + item.classList.remove("streaming"); + item.classList.add("success-flash"); + item.addEventListener("animationend", () => item.classList.remove("success-flash"), { once: true }); + } + + if (item) { + const rawText = item.querySelector(".chat-content")?.getAttribute("data-raw") || ""; + if (!currentState) currentState = {}; + if (!currentState.chat) currentState.chat = {}; + if (!Array.isArray(currentState.chat.messages)) currentState.chat.messages = []; + currentState.chat.messages.push({ + id: (payload && payload.id) || streamingItemId, + role: "assistant", + content: rawText, + createdAt: (payload && payload.createdAt) || nowIso() + }); + } + + streamingItemId = null; + clearBusy(); + }; + + window.addEventListener("message", (event) => { + const msg = event.data; + if (!msg || typeof msg !== "object") return; + + if (msg.type === "STATE_SYNC") { + lastError = undefined; + const syncState = msg.payload || {}; + const taskStatus = ((syncState.activeTask) || {}).status || "idle"; + + const isTerminal = ["idle", "done", "failed", "ready_to_apply"].includes(taskStatus); + const isActive = ["planning", "generating", "reviewing"].includes(taskStatus); + + if (isTerminal) { + clearBusy(); + } else if (isActive && busyCount === 0) { + // The v2 stream may have cleared busy (via CHAT_STREAM_END or + // a premature "done" status_change) but the extension host is + // still working (e.g. batch fallback). Re-activate the thinking + // bubble so the user sees the animation during the entire call. + setBusy(true, getPhaseLabel()); + } + + render(syncState); + return; + } + + if (msg.type === "CHAT_RESPONSE") { + clearBusy(); + pushAssistantMessage(msg.payload); + return; + } + + if (msg.type === "CHAT_STREAM_CHUNK") { + handleStreamChunk(msg.payload); + return; + } + + if (msg.type === "CHAT_STREAM_END") { + finalizeStream(msg.payload); + return; + } + + if (msg.type === "AGENT_TOOL_ACTIVITY") { + handleToolActivity(msg.payload); + return; + } + + if (msg.type === "TOOL_APPROVAL_REQUEST") { + showApprovalCard(msg.payload); + return; + } + + if (msg.type === "PLAN_STEP_UPDATE") { + handlePlanStepUpdate(msg.payload); + return; + } + + if (msg.type === "TERMINAL_OUTPUT") { + handleTerminalOutput(msg.payload); + return; + } + + if (msg.type === "DIAGNOSTICS_RESULT") { + handleDiagnostics(msg.payload); + return; + } + + if (msg.type === "TEST_RESULT") { + handleTestResult(msg.payload); + return; + } + + if (msg.type === "ERROR") { + clearBusy(); + lastError = msg.payload || { title: "Error", message: "Unknown error" }; + render(currentState || {}); + } + }); + + const initialStatusDot = byId("task-status-dot"); + if (initialStatusDot) initialStatusDot.className = "gp-status-dot is-ready"; + + bindStaticEvents(); + post({ type: "INIT" }); diff --git a/extensions/vscode/src/ui/webview/gitpilotWorkspaceTemplate.html b/extensions/vscode/src/ui/webview/gitpilotWorkspaceTemplate.html index 56d7390..67a6ccc 100644 --- a/extensions/vscode/src/ui/webview/gitpilotWorkspaceTemplate.html +++ b/extensions/vscode/src/ui/webview/gitpilotWorkspaceTemplate.html @@ -4,1445 +4,265 @@ - GitPilot Workspace + GitPilot Sidebar -
    -
    -
    -
    -
    - - GitPilot -
    -
    Provider state
    -
    Repo state
    +
    +
    +
    +
    + GitPilot +
    -
    - - -
    -
    - Disconnected - Ready + + +
    +
    Repository state
    +
    -
    - + - - - -
    -
    -

    Chat

    -
    + +
    -
    -
    -
    GP
    -
    What can I help you with?
    -
    Ask GitPilot to explain, review, change, or test your project.
    -
    - - - - -
    + - + - + - \ No newline at end of file + diff --git a/extensions/vscode/src/utils/context.ts b/extensions/vscode/src/utils/context.ts index fbccb5c..2706d22 100644 --- a/extensions/vscode/src/utils/context.ts +++ b/extensions/vscode/src/utils/context.ts @@ -7,6 +7,7 @@ */ import * as vscode from 'vscode'; import * as path from 'path'; +import * as fs from 'fs'; import { execSync } from 'child_process'; export interface WorkspaceContext { @@ -64,10 +65,7 @@ export function getWorkspaceContext(): WorkspaceContext { let hasConfig = false; if (root) { - try { - const fs = require('fs'); - hasConfig = fs.existsSync(path.join(root, '.gitpilot')); - } catch { /* ignore */ } + hasConfig = fs.existsSync(path.join(root, '.gitpilot')); } return { workspaceRoot: root, repoOwner: owner, repoName: name, branch, remoteUrl, isGitRepo, hasGitPilotConfig: hasConfig };