Commit
@@ -20,6 +39,7 @@ function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, g
`;
loadGitStatus();
+ loadGitBranches();
async function loadGitStatus() {
const status = await gitStatus();
@@ -48,6 +68,7 @@ function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, g
${f.status}
${f.file}
+
`
@@ -61,6 +82,13 @@ function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, g
loadGitStatus();
});
});
+
+ changesEl.querySelectorAll('.git-diff-btn').forEach((btn) => {
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ await showDiff(btn.dataset.file);
+ });
+ });
}
// 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 = `
${result.error}
`;
+ return;
+ }
+
+ const names = result.all || [];
+ const current = result.current;
+
+ if (names.length === 0) {
+ branchesEl.innerHTML = '
No branches
';
+ return;
+ }
+
+ branchesEl.innerHTML = names
+ .map(
+ (name) => `
+
+ ${name === current ? '● ' : ''}${name}
+ ${name === current ? '' : ``}
+
+ `
+ )
+ .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 () => {
const msg = document.getElementById('git-commit-msg')?.value?.trim();
if (!msg) return;
@@ -89,6 +171,44 @@ function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, g
document.getElementById('git-commit-msg').value = '';
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 };
diff --git a/src/styles-sidebar.css b/src/styles-sidebar.css
index 177614f..0372f92 100644
--- a/src/styles-sidebar.css
+++ b/src/styles-sidebar.css
@@ -307,6 +307,101 @@ body[class*='dark'] .panel-list-item-desc {
font-size: 13px;
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-toolbar {
@@ -397,11 +492,16 @@ body[class*='dark'] .snippet-delete {
border-color: #444;
color: #ccc;
}
-body[class*='dark'] .git-commit-input {
+body[class*='dark'] .git-commit-input,
+body[class*='dark'] .git-branch-input {
background: #2d2d2d;
border-color: #444;
color: #ccc;
}
+body[class*='dark'] .git-diff-view {
+ background: #2d2d2d;
+ color: #ccc;
+}
body[class*='dark'] .snippet-item {
border-color: #444;
}
@@ -409,7 +509,8 @@ body[class*='dark'] .snippet-preview {
background: #2d2d2d;
}
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;
}
diff --git a/tests/main/GitOperations.test.js b/tests/main/GitOperations.test.js
new file mode 100644
index 0000000..f891620
--- /dev/null
+++ b/tests/main/GitOperations.test.js
@@ -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');
+ });
+ });
+});