mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
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:
+20
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', {
|
||||
|
||||
+121
-1
@@ -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 = `
|
||||
<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">
|
||||
<h4 class="git-section-title">Changes</h4>
|
||||
<div class="git-changes" id="git-changes">
|
||||
<p class="git-loading">Loading...</p>
|
||||
</div>
|
||||
<pre class="git-diff-view" id="git-diff-view" style="display:none;"></pre>
|
||||
</div>
|
||||
<div class="git-section">
|
||||
<h4 class="git-section-title">Commit</h4>
|
||||
@@ -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
|
||||
<div class="git-file" data-file="${f.file}">
|
||||
<span class="git-file-status" style="color:${f.color}">${f.status}</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>
|
||||
</div>
|
||||
`
|
||||
@@ -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 = `<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 ? '● ' : ''}${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 () => {
|
||||
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 };
|
||||
|
||||
+103
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user