feat(git): add diff, branch, checkout, push, pull to Git sidebar panel

Extends GitOperations.js with diff/branches/checkoutBranch/push/pull,
wires the 5 new IPC handlers in main.js (reusing the existing dir
resolution), whitelists the new channels in preload.js, and fixes the
Git sidebar panel's previously dead _gitDiff callback by wiring up a
diff view, branch list/create/checkout UI, and push/pull buttons.
Resolves Task 5, which deferred this work to this task.

Amit Haridas
This commit is contained in:
2026-08-23 19:31:33 +05:30
parent 6ba3174480
commit abcfb03e52
7 changed files with 404 additions and 4 deletions
+20
View File
@@ -5294,6 +5294,26 @@ ipcMain.handle('git-log', async () => {
const dir = currentFile ? path.dirname(currentFile) : process.cwd(); const dir = currentFile ? path.dirname(currentFile) : process.cwd();
return GitOperations.log(dir); return GitOperations.log(dir);
}); });
ipcMain.handle('git-diff', async (event, { file } = {}) => {
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
return GitOperations.diff(dir, file);
});
ipcMain.handle('git-branches', async () => {
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
return GitOperations.branches(dir);
});
ipcMain.handle('git-checkout', async (event, { name, isNew } = {}) => {
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
return GitOperations.checkoutBranch(dir, name, isNew);
});
ipcMain.handle('git-push', async () => {
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
return GitOperations.push(dir);
});
ipcMain.handle('git-pull', async () => {
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
return GitOperations.pull(dir);
});
// ============================================ // ============================================
// Snippets IPC Handlers // Snippets IPC Handlers
+46 -1
View File
@@ -41,4 +41,49 @@ async function log(dir, maxCount = 20) {
} }
} }
module.exports = { getStatus, stage, commit, log }; async function diff(dir, file) {
try {
const git = getGitInstance(dir);
return file ? await git.diff([file]) : await git.diff();
} catch (err) {
return { error: err.message };
}
}
async function branches(dir) {
try {
const git = getGitInstance(dir);
return await git.branchLocal();
} catch (err) {
return { error: err.message };
}
}
async function checkoutBranch(dir, name, isNew) {
try {
const git = getGitInstance(dir);
return isNew ? await git.checkoutLocalBranch(name) : await git.checkout(name);
} catch (err) {
return { error: err.message };
}
}
async function push(dir) {
try {
const git = getGitInstance(dir);
return await git.push();
} catch (err) {
return { error: err.message };
}
}
async function pull(dir) {
try {
const git = getGitInstance(dir);
return await git.pull();
} catch (err) {
return { error: err.message };
}
}
module.exports = { getStatus, stage, commit, log, diff, branches, checkoutBranch, push, pull };
+4
View File
@@ -107,6 +107,10 @@ const ALLOWED_SEND_CHANNELS = [
'git-stage', 'git-stage',
'git-commit', 'git-commit',
'git-log', 'git-log',
'git-branches',
'git-checkout',
'git-push',
'git-pull',
// Snippets // Snippets
'get-snippets', 'get-snippets',
+8
View File
@@ -1751,6 +1751,14 @@ document.addEventListener('DOMContentLoaded', async () => {
message, message,
}), }),
gitLog: () => ipcRenderer.invoke('git-log'), gitLog: () => ipcRenderer.invoke('git-log'),
gitBranches: () => ipcRenderer.invoke('git-branches'),
gitCheckout: (name, isNew) =>
ipcRenderer.invoke('git-checkout', {
name,
isNew,
}),
gitPush: () => ipcRenderer.invoke('git-push'),
gitPull: () => ipcRenderer.invoke('git-pull'),
}), }),
}); });
sidebarManager.registerPanel('snippets', { sidebarManager.registerPanel('snippets', {
+121 -1
View File
@@ -1,11 +1,30 @@
function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, gitLog }) { function renderGitPanel(
container,
{ gitStatus, gitDiff, gitStage, gitCommit, gitLog, gitBranches, gitCheckout, gitPush, gitPull }
) {
container.innerHTML = ` container.innerHTML = `
<div class="git-panel"> <div class="git-panel">
<div class="git-section">
<h4 class="git-section-title">Branches</h4>
<div class="git-branches" id="git-branches">
<p class="git-loading">Loading...</p>
</div>
<div class="git-branch-new">
<input type="text" class="git-branch-input" id="git-branch-input" placeholder="New branch name..." />
<button class="git-branch-create-btn" id="git-branch-create-btn">Create</button>
</div>
<div class="git-remote-actions">
<button class="git-push-btn" id="git-push-btn">Push</button>
<button class="git-pull-btn" id="git-pull-btn">Pull</button>
</div>
<p class="git-remote-status" id="git-remote-status"></p>
</div>
<div class="git-section"> <div class="git-section">
<h4 class="git-section-title">Changes</h4> <h4 class="git-section-title">Changes</h4>
<div class="git-changes" id="git-changes"> <div class="git-changes" id="git-changes">
<p class="git-loading">Loading...</p> <p class="git-loading">Loading...</p>
</div> </div>
<pre class="git-diff-view" id="git-diff-view" style="display:none;"></pre>
</div> </div>
<div class="git-section"> <div class="git-section">
<h4 class="git-section-title">Commit</h4> <h4 class="git-section-title">Commit</h4>
@@ -20,6 +39,7 @@ function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, g
`; `;
loadGitStatus(); loadGitStatus();
loadGitBranches();
async function loadGitStatus() { async function loadGitStatus() {
const status = await gitStatus(); const status = await gitStatus();
@@ -48,6 +68,7 @@ function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, g
<div class="git-file" data-file="${f.file}"> <div class="git-file" data-file="${f.file}">
<span class="git-file-status" style="color:${f.color}">${f.status}</span> <span class="git-file-status" style="color:${f.color}">${f.status}</span>
<span class="git-file-name">${f.file}</span> <span class="git-file-name">${f.file}</span>
<button class="git-diff-btn" data-file="${f.file}" title="View diff">diff</button>
<button class="git-stage-btn" data-file="${f.file}" title="Stage file">+</button> <button class="git-stage-btn" data-file="${f.file}" title="Stage file">+</button>
</div> </div>
` `
@@ -61,6 +82,13 @@ function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, g
loadGitStatus(); loadGitStatus();
}); });
}); });
changesEl.querySelectorAll('.git-diff-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await showDiff(btn.dataset.file);
});
});
} }
// Load log // Load log
@@ -82,6 +110,60 @@ function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, g
} }
} }
async function showDiff(file) {
const diffView = document.getElementById('git-diff-view');
if (!diffView || !gitDiff) return;
const result = await gitDiff(file);
diffView.textContent =
result && result.error ? result.error : result && result.length ? result : 'No changes';
diffView.style.display = 'block';
}
async function loadGitBranches() {
const branchesEl = document.getElementById('git-branches');
if (!gitBranches || !branchesEl) return;
const result = await gitBranches();
if (!result) return;
if (result.error) {
branchesEl.innerHTML = `<p class="git-info">${result.error}</p>`;
return;
}
const names = result.all || [];
const current = result.current;
if (names.length === 0) {
branchesEl.innerHTML = '<p class="git-info">No branches</p>';
return;
}
branchesEl.innerHTML = names
.map(
(name) => `
<div class="git-branch-item${name === current ? ' git-branch-current' : ''}" data-branch="${name}">
<span class="git-branch-name">${name === current ? '&#9679; ' : ''}${name}</span>
${name === current ? '' : `<button class="git-checkout-btn" data-branch="${name}" title="Checkout branch">checkout</button>`}
</div>
`
)
.join('');
branchesEl.querySelectorAll('.git-checkout-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const result = await gitCheckout(btn.dataset.branch, false);
if (result && result.error) {
const statusEl = document.getElementById('git-remote-status');
if (statusEl) statusEl.textContent = `Checkout failed: ${result.error}`;
}
loadGitBranches();
loadGitStatus();
});
});
}
document.getElementById('git-commit-btn')?.addEventListener('click', async () => { document.getElementById('git-commit-btn')?.addEventListener('click', async () => {
const msg = document.getElementById('git-commit-msg')?.value?.trim(); const msg = document.getElementById('git-commit-msg')?.value?.trim();
if (!msg) return; if (!msg) return;
@@ -89,6 +171,44 @@ function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, g
document.getElementById('git-commit-msg').value = ''; document.getElementById('git-commit-msg').value = '';
loadGitStatus(); loadGitStatus();
}); });
document.getElementById('git-branch-create-btn')?.addEventListener('click', async () => {
const input = document.getElementById('git-branch-input');
const name = input?.value?.trim();
if (!name || !gitCheckout) return;
const result = await gitCheckout(name, true);
const statusEl = document.getElementById('git-remote-status');
if (result && result.error) {
if (statusEl) statusEl.textContent = `Create branch failed: ${result.error}`;
} else {
input.value = '';
if (statusEl) statusEl.textContent = '';
}
loadGitBranches();
loadGitStatus();
});
document.getElementById('git-push-btn')?.addEventListener('click', async () => {
const statusEl = document.getElementById('git-remote-status');
if (!gitPush) return;
const result = await gitPush();
if (statusEl) {
statusEl.textContent =
result && result.error ? `Push failed: ${result.error}` : 'Push complete';
}
});
document.getElementById('git-pull-btn')?.addEventListener('click', async () => {
const statusEl = document.getElementById('git-remote-status');
if (!gitPull) return;
const result = await gitPull();
if (statusEl) {
statusEl.textContent =
result && result.error ? `Pull failed: ${result.error}` : 'Pull complete';
}
loadGitStatus();
loadGitBranches();
});
} }
module.exports = { renderGitPanel }; module.exports = { renderGitPanel };
+103 -2
View File
@@ -307,6 +307,101 @@ body[class*='dark'] .panel-list-item-desc {
font-size: 13px; font-size: 13px;
color: var(--gray-400); color: var(--gray-400);
} }
.git-diff-btn {
border: none;
background: var(--gray-200);
border-radius: 4px;
cursor: pointer;
font-size: 11px;
padding: 2px 6px;
}
.git-diff-view {
margin-top: 8px;
max-height: 240px;
overflow: auto;
padding: 8px;
background: var(--gray-100, #f3f4f6);
border-radius: 6px;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.git-branch-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px;
border-radius: 4px;
font-size: 13px;
gap: 6px;
}
.git-branch-item:hover {
background: var(--gray-100, #f3f4f6);
}
.git-branch-current {
font-weight: 600;
}
.git-branch-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.git-checkout-btn {
border: none;
background: var(--gray-200);
border-radius: 4px;
cursor: pointer;
font-size: 11px;
padding: 2px 6px;
}
.git-branch-new {
display: flex;
gap: 4px;
margin-top: 8px;
}
.git-branch-input {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--gray-300);
border-radius: 6px;
font-size: 13px;
box-sizing: border-box;
}
.git-branch-create-btn {
border: none;
background: var(--gray-200);
border-radius: 6px;
cursor: pointer;
font-size: 12px;
padding: 6px 8px;
}
.git-remote-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.git-push-btn,
.git-pull-btn {
flex: 1;
padding: 8px;
background: var(--primary-dark, #5661b3);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.git-push-btn:hover,
.git-pull-btn:hover {
opacity: 0.9;
}
.git-remote-status {
font-size: 12px;
color: var(--gray-500);
margin-top: 6px;
min-height: 14px;
}
/* Snippets Panel */ /* Snippets Panel */
.snippets-toolbar { .snippets-toolbar {
@@ -397,11 +492,16 @@ body[class*='dark'] .snippet-delete {
border-color: #444; border-color: #444;
color: #ccc; color: #ccc;
} }
body[class*='dark'] .git-commit-input { body[class*='dark'] .git-commit-input,
body[class*='dark'] .git-branch-input {
background: #2d2d2d; background: #2d2d2d;
border-color: #444; border-color: #444;
color: #ccc; color: #ccc;
} }
body[class*='dark'] .git-diff-view {
background: #2d2d2d;
color: #ccc;
}
body[class*='dark'] .snippet-item { body[class*='dark'] .snippet-item {
border-color: #444; border-color: #444;
} }
@@ -409,7 +509,8 @@ body[class*='dark'] .snippet-preview {
background: #2d2d2d; background: #2d2d2d;
} }
body[class*='dark'] .tree-item:hover, body[class*='dark'] .tree-item:hover,
body[class*='dark'] .git-file:hover { body[class*='dark'] .git-file:hover,
body[class*='dark'] .git-branch-item:hover {
background: #333; background: #333;
} }
+102
View File
@@ -0,0 +1,102 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const simpleGit = require('simple-git');
const GitOperations = require('../../src/main/GitOperations');
describe('GitOperations', () => {
let tmpDir, filePath;
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitops_'));
const git = simpleGit(tmpDir);
await git.init();
await git.addConfig('user.name', 'Test User');
await git.addConfig('user.email', 'test@example.com');
filePath = path.join(tmpDir, 'file.txt');
fs.writeFileSync(filePath, 'line1\n');
await git.add(['file.txt']);
await git.commit('initial commit');
});
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
describe('diff', () => {
test('returns full working-tree diff when no file given', async () => {
fs.writeFileSync(filePath, 'line1\nline2\n');
const result = await GitOperations.diff(tmpDir);
expect(typeof result).toBe('string');
expect(result).toContain('file.txt');
expect(result).toContain('+line2');
});
test('returns diff scoped to a single file', async () => {
fs.writeFileSync(filePath, 'line1\nline2\n');
const result = await GitOperations.diff(tmpDir, 'file.txt');
expect(typeof result).toBe('string');
expect(result).toContain('+line2');
});
test('returns error object for non-git directory', async () => {
const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notgit_'));
const result = await GitOperations.diff(nonGitDir);
expect(result).toHaveProperty('error');
fs.rmSync(nonGitDir, { recursive: true, force: true });
});
});
describe('branches', () => {
test('returns local branch summary with current branch set', async () => {
const result = await GitOperations.branches(tmpDir);
expect(result).toHaveProperty('all');
expect(result).toHaveProperty('current');
expect(result).toHaveProperty('branches');
expect(result.all).toContain(result.current);
});
test('returns error object for non-git directory', async () => {
const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notgit_'));
const result = await GitOperations.branches(nonGitDir);
expect(result).toHaveProperty('error');
fs.rmSync(nonGitDir, { recursive: true, force: true });
});
});
describe('checkoutBranch', () => {
test('creates and switches to a new branch when isNew is true', async () => {
const result = await GitOperations.checkoutBranch(tmpDir, 'feature-x', true);
expect(result).not.toHaveProperty('error');
const branchInfo = await GitOperations.branches(tmpDir);
expect(branchInfo.current).toBe('feature-x');
});
test('switches to an existing branch when isNew is false', async () => {
const initialBranches = await GitOperations.branches(tmpDir);
const original = initialBranches.current;
await GitOperations.checkoutBranch(tmpDir, 'feature-y', true);
const result = await GitOperations.checkoutBranch(tmpDir, original, false);
expect(result).not.toHaveProperty('error');
const branchInfo = await GitOperations.branches(tmpDir);
expect(branchInfo.current).toBe(original);
});
test('returns error object when checking out a nonexistent branch', async () => {
const result = await GitOperations.checkoutBranch(tmpDir, 'does-not-exist', false);
expect(result).toHaveProperty('error');
});
});
describe('push', () => {
test('returns error object when no remote is configured', async () => {
const result = await GitOperations.push(tmpDir);
expect(result).toHaveProperty('error');
});
});
describe('pull', () => {
test('returns error object when no remote is configured', async () => {
const result = await GitOperations.pull(tmpDir);
expect(result).toHaveProperty('error');
});
});
});