Compare commits

...

10 Commits

17 changed files with 624 additions and 9 deletions

View File

@@ -41,6 +41,7 @@ class VSCodePlugin(Plugin):
self.vscode_port = int(os.environ['VSCODE_PORT'])
self.vscode_connection_token = str(uuid.uuid4())
assert check_port_available(self.vscode_port)
cmd = (
f"su - {username} -s /bin/bash << 'EOF'\n"
f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'

View File

@@ -1,4 +1,7 @@
{
"workbench.colorTheme": "Default Dark Modern",
"workbench.startupEditor": "none"
"workbench.startupEditor": "none",
"workbench.startupEditorAssociations": {
"openhands-changes-view": "openhands-changes-viewer.openhands-changes-view"
}
}

View File

@@ -90,12 +90,10 @@ RUN if [ -z "${RELEASE_TAG}" ]; then \
{% endmacro %}
{% macro install_vscode_extensions() %}
# Install our custom extension
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world && \
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/hello-world/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world/
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor && \
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/memory-monitor/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor/
# Install our custom extensions
# Copy all extensions from source to destination using a recursive approach
# The extension folders are named to match their package names (openhands-*)
RUN cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/* ${OPENVSCODE_SERVER_ROOT}/extensions/
# Some extension dirs are removed because they trigger false positives in vulnerability scans.
RUN rm -rf ${OPENVSCODE_SERVER_ROOT}/extensions/{handlebars,pug,json,diff,grunt,ini,npm}
@@ -130,6 +128,7 @@ RUN /openhands/micromamba/bin/micromamba run -n openhands poetry cache clear --a
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
/openhands/micromamba/bin/micromamba clean --all
{{ setup_vscode_server() }}
{% endmacro %}
{% if build_from_scratch %}
@@ -175,14 +174,13 @@ COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
COPY ./code/openhands /openhands/code/openhands
RUN chmod a+rwx /openhands/code/openhands/__init__.py
{{ setup_vscode_server() }}
{{ install_vscode_extensions() }}
# ================================================================
# END: Build from versioned image
# ================================================================
{% if build_from_versioned %}
{{ install_dependencies() }}
{{ install_vscode_extensions() }}
{% endif %}
# Install extra dependencies if specified

View File

@@ -0,0 +1,39 @@
# OpenHands Changes Viewer
A VSCode extension for viewing changes made by the OpenHands agent.
## Features
- Shows a list of changed files in the workspace
- Displays the status of each file (Added, Modified, Deleted)
- Allows viewing the diff of each file
- Automatically refreshes the list of changes every 10 seconds
- Opens automatically at VSCode startup
## Usage
The extension adds a new view container to the activity bar with a "Changes" icon. Click on it to see the list of changed files.
- Click on a file to view its diff
- Click the refresh button to manually refresh the list of changes
- Right-click on a file to see additional options
## Requirements
- VSCode 1.98.2 or higher
- Git must be installed and available in the PATH
## Extension Settings
This extension does not contribute any settings.
## Known Issues
- The diff view is basic and does not support all the features of the built-in diff editor
- The extension may not work correctly in workspaces without Git
## Release Notes
### 0.1.0
Initial release of OpenHands Changes Viewer

View File

@@ -0,0 +1,113 @@
const vscode = require('vscode');
/**
* TreeDataProvider for the Changes view
*/
class ChangesProvider {
/**
* @param {import('./git-service').GitService} gitService
*/
constructor(gitService) {
this.gitService = gitService;
this._onDidChangeTreeData = new vscode.EventEmitter();
this.onDidChangeTreeData = this._onDidChangeTreeData.event;
}
/**
* Refresh the tree view
*/
refresh() {
this._onDidChangeTreeData.fire();
}
/**
* Get the tree item for the given element
* @param {any} element
* @returns {vscode.TreeItem}
*/
getTreeItem(element) {
return element;
}
/**
* Get the children of the given element
* @param {any} element
* @returns {Promise<vscode.TreeItem[]>}
*/
async getChildren(element) {
if (element) {
return [];
}
try {
const changes = await this.gitService.getChanges();
if (!changes || changes.length === 0) {
return [new vscode.TreeItem('No changes detected', vscode.TreeItemCollapsibleState.None)];
}
return changes.map(change => {
const { path, status } = change;
// Create a tree item for each file
const treeItem = new vscode.TreeItem(path, vscode.TreeItemCollapsibleState.None);
// Set the context value to 'file' so we can use it in the when clause
treeItem.contextValue = 'file';
// Store the file path and status in the tree item
treeItem.id = path;
treeItem.tooltip = `${this.getStatusLabel(status)}: ${path}`;
treeItem.command = {
command: 'openhands-changes-viewer.viewDiff',
title: 'View Diff',
arguments: [change]
};
// Set the icon based on the status
treeItem.iconPath = this.getIconForStatus(status);
return treeItem;
});
} catch (error) {
vscode.window.showErrorMessage(`Failed to get changes: ${error.message}`);
return [new vscode.TreeItem(`Error: ${error.message}`, vscode.TreeItemCollapsibleState.None)];
}
}
/**
* Get a human-readable label for a git status
* @param {string} status
* @returns {string}
*/
getStatusLabel(status) {
const statusMap = {
'M': 'Modified',
'A': 'Added',
'D': 'Deleted',
'R': 'Renamed',
'U': 'Untracked'
};
return statusMap[status] || status;
}
/**
* Get the icon for a git status
* @param {string} status
* @returns {vscode.ThemeIcon}
*/
getIconForStatus(status) {
const iconMap = {
'M': new vscode.ThemeIcon('edit'),
'A': new vscode.ThemeIcon('add'),
'D': new vscode.ThemeIcon('trash'),
'R': new vscode.ThemeIcon('arrow-right'),
'U': new vscode.ThemeIcon('question')
};
return iconMap[status] || new vscode.ThemeIcon('file');
}
}
module.exports = { ChangesProvider };

View File

@@ -0,0 +1,200 @@
const vscode = require('vscode');
const { ChangesProvider } = require('./changes-provider');
const { GitService } = require('./git-service');
/**
* @param {vscode.ExtensionContext} context
*/
function activate(context) {
console.log('OpenHands Changes Viewer is now active');
// Initialize the Git service
const gitService = new GitService();
// Create the tree data provider for the changes view
const changesProvider = new ChangesProvider(gitService);
// Register the tree data provider for the changes view
const treeView = vscode.window.createTreeView('openhands-changes-view', {
treeDataProvider: changesProvider,
showCollapseAll: true
});
// Register the refresh command
let refreshCommand = vscode.commands.registerCommand('openhands-changes-viewer.refreshChanges', () => {
changesProvider.refresh();
});
// Register the view diff command
let viewDiffCommand = vscode.commands.registerCommand('openhands-changes-viewer.viewDiff', async (fileItem) => {
if (fileItem) {
const { path, status } = fileItem;
try {
// Show a progress notification
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: `Loading diff for ${path}`,
cancellable: false
}, async (progress) => {
try {
progress.report({ increment: 30, message: "Fetching file content..." });
const diff = await gitService.getDiff(path);
progress.report({ increment: 30, message: "Processing diff..." });
// Create a temporary file for the diff
// Use a safe filename for the URI
const safeFileName = path.replace(/[\\/:*?"<>|]/g, '_');
const uri = vscode.Uri.parse(`untitled:${safeFileName}.diff`);
// Determine if this is a new file, deleted file, or modified file
let content = '';
if (status === 'A') {
// New file - show only the new content
content = diff.modified || '(Empty file)';
} else if (status === 'D') {
// Deleted file - show only the original content
content = diff.original || '(Empty file)';
} else {
// For modified files, show a diff header
content = `--- a/${path}\n+++ b/${path}\n\n${diff.original || '(Empty file)'}\n\n=== Modified ===\n\n${diff.modified || '(Empty file)'}`;
}
progress.report({ increment: 20, message: "Creating document..." });
// Create a new document with the diff content
const edit = new vscode.WorkspaceEdit();
edit.createFile(uri, { overwrite: true });
await vscode.workspace.applyEdit(edit);
const document = await vscode.workspace.openTextDocument(uri);
const editor = await vscode.window.showTextDocument(document);
progress.report({ increment: 20, message: "Applying content..." });
// Insert the diff content
const fullRange = new vscode.Range(
0, 0,
document.lineCount, 0
);
await editor.edit(editBuilder => {
editBuilder.replace(fullRange, content);
});
// Set the language mode to help with syntax highlighting
await vscode.languages.setTextDocumentLanguage(document, getLanguageFromPath(path));
} catch (error) {
throw error; // Re-throw to be caught by the outer try-catch
}
});
} catch (error) {
console.error('Error showing diff:', error);
vscode.window.showErrorMessage(`Failed to show diff for ${path}: ${error.message}`);
}
} else {
vscode.window.showErrorMessage('No file selected to view diff');
}
});
// Register the open changes view command
let openChangesViewCommand = vscode.commands.registerCommand('openhands-changes-viewer.openChangesView', () => {
vscode.commands.executeCommand('workbench.view.extension.openhands-changes');
});
// Open the changes view when the extension is activated
vscode.commands.executeCommand('workbench.view.extension.openhands-changes');
// Add commands to subscriptions
context.subscriptions.push(refreshCommand);
context.subscriptions.push(viewDiffCommand);
context.subscriptions.push(openChangesViewCommand);
context.subscriptions.push(treeView);
// Auto-refresh the changes view every 10 seconds
const intervalId = setInterval(() => {
changesProvider.refresh();
}, 10000);
// Clean up the interval when the extension is deactivated
context.subscriptions.push({ dispose: () => clearInterval(intervalId) });
}
/**
* Get the language ID from a file path for syntax highlighting
* @param {string} path
* @returns {string}
*/
function getLanguageFromPath(path) {
// Handle files without extensions or with special names
if (!path.includes('.')) {
// Check for common files without extensions
const filename = path.split('/').pop().toLowerCase();
if (['makefile', 'dockerfile', 'jenkinsfile'].includes(filename)) {
return filename.toLowerCase();
}
if (filename === 'readme') return 'markdown';
return 'plaintext';
}
const extension = path.split('.').pop().toLowerCase();
const extensionMap = {
'js': 'javascript',
'ts': 'typescript',
'tsx': 'typescriptreact',
'jsx': 'javascriptreact',
'py': 'python',
'java': 'java',
'c': 'c',
'cpp': 'cpp',
'h': 'c',
'hpp': 'cpp',
'cs': 'csharp',
'go': 'go',
'rs': 'rust',
'php': 'php',
'rb': 'ruby',
'md': 'markdown',
'json': 'json',
'html': 'html',
'htm': 'html',
'css': 'css',
'scss': 'scss',
'less': 'less',
'xml': 'xml',
'yaml': 'yaml',
'yml': 'yaml',
'sh': 'shellscript',
'bash': 'shellscript',
'zsh': 'shellscript',
'txt': 'plaintext',
'sql': 'sql',
'swift': 'swift',
'kt': 'kotlin',
'dart': 'dart',
'vue': 'vue',
'svelte': 'svelte',
'tf': 'terraform',
'tfvars': 'terraform',
'graphql': 'graphql',
'gql': 'graphql',
'toml': 'toml',
'ini': 'ini',
'bat': 'bat',
'ps1': 'powershell'
};
return extensionMap[extension] || 'plaintext';
}
function deactivate() {
console.log('OpenHands Changes Viewer is now deactivated');
}
module.exports = {
activate,
deactivate
};

View File

@@ -0,0 +1,152 @@
const vscode = require('vscode');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
/**
* Service for interacting with Git
*/
class GitService {
constructor() {
this.workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
}
/**
* Get the list of changed files
* @returns {Promise<Array<{path: string, status: string}>>}
*/
async getChanges() {
if (!this.workspaceRoot) {
throw new Error('No workspace folder is open');
}
try {
// Run git status to get the list of changed files
// Use -z to handle filenames with special characters
const { stdout } = await execAsync('git status --porcelain -z', { cwd: this.workspaceRoot });
if (!stdout) {
return [];
}
// Parse the output of git status with null-terminated lines
const changes = [];
const entries = stdout.split('\0');
for (let i = 0; i < entries.length - 1; i++) {
const entry = entries[i];
if (entry.length >= 2) {
const status = entry.substring(0, 2).trim();
const path = entry.substring(3);
// Skip empty paths
if (!path) continue;
// Map the git status to our status codes
let mappedStatus = 'M'; // Default to modified
if (status.includes('A') || status.includes('?')) {
mappedStatus = 'A'; // Added or untracked
} else if (status.includes('D')) {
mappedStatus = 'D'; // Deleted
} else if (status.includes('R')) {
mappedStatus = 'R'; // Renamed
}
changes.push({ path, status: mappedStatus });
}
}
return changes;
} catch (error) {
console.error('Error getting git changes:', error);
throw new Error(`Failed to get git changes: ${error.message}`);
}
}
/**
* Get the diff for a file
* @param {string} filePath
* @returns {Promise<{original: string, modified: string}>}
*/
async getDiff(filePath) {
if (!this.workspaceRoot) {
throw new Error('No workspace folder is open');
}
try {
// Properly escape the file path for shell commands
const escapedPath = filePath.replace(/'/g, "'\\''");
// Check if the file exists in the index
const { stdout: fileStatus } = await execAsync(`git status --porcelain -- '${escapedPath}'`, {
cwd: this.workspaceRoot
});
const status = fileStatus.substring(0, 2).trim();
let original = '';
let modified = '';
if (status.includes('?') || status.includes('A')) {
// New file - get the current content
try {
const { stdout: currentContent } = await execAsync(`cat '${escapedPath}'`, {
cwd: this.workspaceRoot
});
original = '';
modified = currentContent;
} catch (error) {
console.warn(`Warning: Could not read new file: ${error.message}`);
original = '';
modified = '';
}
} else if (status.includes('D')) {
// Deleted file - get the content from the index
try {
// Use a more reliable way to get the file from git
const { stdout: indexContent } = await execAsync(`git show HEAD:'${escapedPath}'`, {
cwd: this.workspaceRoot
});
original = indexContent;
modified = '';
} catch (error) {
console.warn(`Warning: Could not get deleted file content: ${error.message}`);
original = '';
modified = '';
}
} else {
// Modified file - get both versions
try {
// Get the content from the index
const { stdout: indexContent } = await execAsync(`git show HEAD:'${escapedPath}'`, {
cwd: this.workspaceRoot
});
original = indexContent;
} catch (error) {
// File might not exist in the index yet
console.warn(`Warning: Could not get original file content: ${error.message}`);
original = '';
}
try {
// Get the current content
const { stdout: currentContent } = await execAsync(`cat '${escapedPath}'`, {
cwd: this.workspaceRoot
});
modified = currentContent;
} catch (error) {
// File might not exist in the workspace
console.warn(`Warning: Could not get modified file content: ${error.message}`);
modified = '';
}
}
return { original, modified };
} catch (error) {
console.error('Error getting diff:', error);
throw new Error(`Failed to get diff: ${error.message}`);
}
}
}
module.exports = { GitService };

View File

@@ -0,0 +1,79 @@
{
"name": "openhands-changes-viewer",
"displayName": "OpenHands Changes Viewer",
"description": "A VSCode extension for viewing changes made by the OpenHands agent",
"version": "0.1.0",
"publisher": "openhands",
"engines": {
"vscode": "^1.98.2"
},
"categories": [
"Other",
"SCM Providers"
],
"activationEvents": [
"onStartupFinished",
"onCommand:openhands-changes-viewer.openChangesView"
],
"main": "./extension.js",
"contributes": {
"viewsContainers": {
"activitybar": [
{
"id": "openhands-changes",
"title": "OpenHands Changes",
"icon": "resources/changes.svg"
}
]
},
"views": {
"openhands-changes": [
{
"id": "openhands-changes-view",
"name": "Changes",
"when": "true"
}
]
},
"commands": [
{
"command": "openhands-changes-viewer.refreshChanges",
"title": "Refresh Changes",
"icon": "$(refresh)"
},
{
"command": "openhands-changes-viewer.viewDiff",
"title": "View Diff"
},
{
"command": "openhands-changes-viewer.openChangesView",
"title": "OpenHands: Show Changes View",
"category": "OpenHands"
}
],
"menus": {
"view/title": [
{
"command": "openhands-changes-viewer.refreshChanges",
"when": "view == openhands-changes-view",
"group": "navigation"
}
],
"view/item/context": [
{
"command": "openhands-changes-viewer.viewDiff",
"when": "view == openhands-changes-view && viewItem == file",
"group": "inline"
}
]
},
"keybindings": [
{
"command": "openhands-changes-viewer.openChangesView",
"key": "ctrl+shift+c",
"mac": "cmd+shift+c",
"when": "editorTextFocus"
}
]
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 1H2.5C1.67157 1 1 1.67157 1 2.5V13.5C1 14.3284 1.67157 15 2.5 15H13.5C14.3284 15 15 14.3284 15 13.5V2.5C15 1.67157 14.3284 1 13.5 1Z" stroke="#C5C5C5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 4H12" stroke="#C5C5C5" stroke-width="1.5" stroke-linecap="round"/>
<path d="M4 8H12" stroke="#C5C5C5" stroke-width="1.5" stroke-linecap="round"/>
<path d="M4 12H8" stroke="#C5C5C5" stroke-width="1.5" stroke-linecap="round"/>
<path d="M11 11L13 13M13 11L11 13" stroke="#C5C5C5" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 718 B

View File

@@ -0,0 +1,22 @@
const { GitService } = require('./git-service');
async function testGitService() {
const gitService = new GitService();
try {
console.log('Getting changes...');
const changes = await gitService.getChanges();
console.log('Changes:', changes);
if (changes.length > 0) {
const firstChange = changes[0];
console.log(`Getting diff for ${firstChange.path}...`);
const diff = await gitService.getDiff(firstChange.path);
console.log('Diff:', diff);
}
} catch (error) {
console.error('Error:', error);
}
}
testGitService();