diff --git a/src/main.js b/src/main.js index 3d7bebb..c6d4b38 100644 --- a/src/main.js +++ b/src/main.js @@ -5294,6 +5294,26 @@ ipcMain.handle('git-log', async () => { const dir = currentFile ? path.dirname(currentFile) : process.cwd(); 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 diff --git a/src/main/GitOperations.js b/src/main/GitOperations.js index 862f5d2..325226f 100644 --- a/src/main/GitOperations.js +++ b/src/main/GitOperations.js @@ -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 }; diff --git a/src/preload.js b/src/preload.js index 49d8a17..0baa6b4 100644 --- a/src/preload.js +++ b/src/preload.js @@ -107,6 +107,10 @@ const ALLOWED_SEND_CHANNELS = [ 'git-stage', 'git-commit', 'git-log', + 'git-branches', + 'git-checkout', + 'git-push', + 'git-pull', // Snippets 'get-snippets', diff --git a/src/renderer.js b/src/renderer.js index ee10e83..fb63aa1 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -1751,6 +1751,14 @@ document.addEventListener('DOMContentLoaded', async () => { message, }), 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', { diff --git a/src/sidebar/git-panel.js b/src/sidebar/git-panel.js index 5505894..a5cc063 100644 --- a/src/sidebar/git-panel.js +++ b/src/sidebar/git-panel.js @@ -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 = `
+
+

Branches

+
+

Loading...

+
+
+ + +
+
+ + +
+

+

Changes

Loading...

+

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'); + }); + }); +});