Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 26 additions & 73 deletions UI/git_sidebar/gitman.js
Original file line number Diff line number Diff line change
@@ -1,94 +1,47 @@
const { exec, execFile } = require("child_process");

class GitManager {
constructor(options = {}) {
// Directory where Git commands will run
this.cwd = options.cwd || process.cwd();

// Optional: enable verbose logging
this.verbose = options.verbose || false;
}

run(cmd) {
return new Promise((resolve, reject) => {
exec(cmd, { cwd: this.cwd }, (err, stdout, stderr) => {
if (this.verbose) {
console.log("[GitManager] CMD: ", cmd);
console.log("[GitManager] OUT: ", stdout);
console.log("[GitManager] ERR: ", stderr);
}
if (err) {
const msg = stdout.trim() || stderr.trim();
reject(msg);
return;
}
resolve(stdout.trim());
});
});
}

runGit(operation, name) {
return new Promise((resolve, reject) => {
execFile("git", [operation, name], { cwd: this.cwd }, (err, stdout, stderr) => {
if (this.verbose) {
console.log("[GitManager] CMD: ", "git", operation, name);
console.log("[GitManager] OUT: ", stdout);
console.log("[GitManager] ERR: ", stderr);
}
if (err) {
const msg = stdout.trim() || stderr.trim();
reject(msg);
return;
}
resolve(stdout.trim());
});
});
this.api = options.api;
this.workspace = options.workspace || null;
}

async init() { // Function to handle git init
return this.run("git init");
setWorkspace(workspace) {
this.workspace = workspace || null;
}

async addAll() { // Function to handle git add .
return this.run("git add .");
async run(operation, ...args) {
if (!this.workspace) throw new Error('Open a folder to use Git.');
if (!this.api || typeof this.api.run !== 'function') throw new Error('Git API is unavailable.');
const result = await this.api.run(operation, this.workspace, args);
if (!result.ok) {
const error = new Error(result.stderr || result.stdout || 'Git command failed.');
error.code = result.code;
throw error;
}
return result.stdout;
}

async commit(message) { // Function to handle git commit -m ""
return new Promise((resolve, reject) => {
execFile("git", ["commit", "-m", message], { cwd: this.cwd }, (err, stdout, stderr) => {
if (this.verbose) {
console.log("[GitManager] CMD: ", "git commit -m", message);
console.log("[GitManager] OUT: ", stdout);
console.log("[GitManager] ERR: ", stderr);
}
if (err) {
const msg = stdout.trim() || stderr.trim();
reject(msg);
return;
}
resolve(stdout.trim());
});
});
async init() {
return this.run('init');
}

async status() { // Function to handle git status
return this.run("git status --short");
async addAll() {
return this.run('addAll');
}

async push() { // Function to handle git push
return this.run("git push");
async commit(message) {
return this.run('commit', message);
}

async pull() { // Function to handle git pull
return this.run("git pull");
async status() {
return this.run('status');
}

async branch(name) { // Function to handle git branch
return this.runGit("branch", name);
async push() {
return this.run('push');
}

async switch(name) { // Function to handle git switch branch
return this.runGit("switch", name);
async pull() {
return this.run('pull');
}
}

Expand Down
16 changes: 16 additions & 0 deletions UI/git_sidebar/gitman.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const GitManager = require('./gitman');

test('passes the selected workspace and arguments to the Git bridge', async () => {
let request;
const manager = new GitManager({ api: { run: async (...args) => { request = args; return { ok: true, stdout: 'done' }; } } });
manager.setWorkspace('/selected/workspace');
assert.equal(await manager.commit('message with spaces'), 'done');
assert.deepEqual(request, ['commit', '/selected/workspace', ['message with spaces']]);
});

test('rejects structured bridge errors', async () => {
const manager = new GitManager({ workspace: '/workspace', api: { run: async () => ({ ok: false, stderr: 'failed', code: 1 }) } });
await assert.rejects(manager.status(), /failed/);
});
6 changes: 3 additions & 3 deletions UI/git_sidebar/gitstub.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
class StatusStub {
static parse(raw) {
if (!raw.trim()) {
if (!raw || !raw.trim()) {
return [];
}

return raw.split("\n").map(line => {
return raw.split(/\r?\n/).filter(Boolean).map(line => {
const code = line.slice(0, 2);
const file = line.slice(2).trim();
return { code, file };
Expand Down Expand Up @@ -47,4 +47,4 @@ module.exports = {
StatusStub,
BranchStub,
LogStub
}
};
10 changes: 10 additions & 0 deletions UI/git_sidebar/gitstub.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { StatusStub } = require('./gitstub');

test('parses modified, added, deleted, renamed, and untracked files', () => {
assert.deepEqual(StatusStub.parse(' M modified.js\nA added.js\n D deleted.js\nR old.js -> new.js\n?? untracked.js'), [
{ code: ' M', file: 'modified.js' }, { code: 'A ', file: 'added.js' }, { code: ' D', file: 'deleted.js' },
{ code: 'R ', file: 'old.js -> new.js' }, { code: '??', file: 'untracked.js' }
]);
});
173 changes: 124 additions & 49 deletions UI/git_sidebar/gitui.js
Original file line number Diff line number Diff line change
@@ -1,60 +1,135 @@
const GitManager = require('./gitman');
const { generateUnifiedDiff } = require('../../patchgen');
const { StatusStub } = require('./gitstub');

class GitUi {
constructor(containerEl, gitBar) {
this.gitman = new GitManager();
this.containerEl = containerEl;
this.patch = generateUnifiedDiff;
this.gitBar = gitBar;
constructor(containerEl, options = {}) {
this.containerEl = containerEl;
this.gitman = options.gitman || new GitManager({ api: options.gitApi });
this.workspace = null;
this.state = { mode: 'empty', entries: [], message: 'Open a folder to use Git.', messageType: 'info' };
this.listening = false;
}

async setWorkspace(workspace) {
this.workspace = workspace || null;
this.gitman.setWorkspace(this.workspace);
if (!this.workspace) {
this.state = { mode: 'empty', entries: [], message: 'Open a folder to use Git.', messageType: 'info' };
this.render();
return;
}
await this.refresh();
}

initListeners() {
if (!this.containerEl || this.listening) return;
this.listening = true;
this.containerEl.addEventListener('click', (event) => this.handleClick(event));
this.containerEl.addEventListener('input', (event) => {
if (event.target.id === 'git-commit-message') this.renderActionState();
});
}

init_listners() { this.initListeners(); }

async handleClick(event) {
const action = event.target.dataset?.gitAction;
if (!action) return;
if (action === 'refresh') return this.refresh();
const operations = { init: 'init', stage: 'addAll', commit: 'commit', pull: 'pull', push: 'push' };
const method = operations[action];
if (!method) return;
const message = action === 'commit' ? this.containerEl.querySelector('#git-commit-message')?.value.trim() : undefined;
if (action === 'commit' && !message) return;
await this.runAction(method, message);
}

async runAction(method, argument) {
this.setMessage(`${this.actionLabel(method)}…`, 'loading');
try {
const output = await this.gitman[method](argument);
await this.refresh(`${this.actionLabel(method)} completed${output ? `: ${output}` : '.'}`);
} catch (error) {
this.setMessage(error.message || String(error), 'error');
}
}

init_listners() {
this.containerEl.addEventListener('click', (e) => {
if (e.target.id === 'git-init-btn') {
this.gitman.init().then((output) => {
console.log("[GitUi MSG] : ", output);
});
}
});

this.containerEl.addEventListener('click', (e) => {
if (e.target.id === 'git-add-btn') {
this.gitman.addAll().then((output) => {
console.log("[GitUi MSG] : ", output);
});
}
});

this.containerEl.addEventListener('click', (e) => {
if (e.target.id === 'git-patch-btn') {
const oldFile = this.containerEl.querySelector('#git-old-file').files[0];
const newFile = this.containerEl.querySelector('#git-new-file').files[0];
if (!oldFile || !newFile) return;

const oldFilePath = this.gitBar.getPathForFile(oldFile);
const newFilePath = this.gitBar.getPathForFile(newFile);
const output = this.patch(oldFilePath, newFilePath);
console.log("[GitUi MSG] : ", output);
}
});
actionLabel(method) {
return { init: 'Initializing repository', addAll: 'Staging files', commit: 'Committing changes', pull: 'Pulling changes', push: 'Pushing changes' }[method];
}

async refresh(successMessage = '') {
if (!this.workspace) return;
this.state = { ...this.state, mode: 'loading', message: 'Loading repository status…', messageType: 'loading' };
this.render();
try {
const raw = await this.gitman.status();
this.state = { mode: 'repository', entries: StatusStub.parse(raw), message: successMessage || 'Repository status is up to date.', messageType: successMessage ? 'success' : 'info' };
} catch (error) {
if (/not a git repository/i.test(error.message || '')) {
this.state = { mode: 'non-repository', entries: [], message: 'This folder is not a Git repository.', messageType: 'info' };
} else {
this.state = { mode: 'error', entries: [], message: error.message || String(error), messageType: 'error' };
}
}
this.render();
}

setMessage(message, messageType) {
this.state.message = message;
this.state.messageType = messageType;
this.render();
}

create(tagName, properties = {}, text = '') {
const element = this.containerEl.ownerDocument.createElement(tagName);
Object.assign(element, properties);
if (text) element.textContent = text;
return element;
}

render() {
if (!this.containerEl) return;

this.containerEl.innerHTML = `
<div class="git_bar">
<button id="git-init-btn" class="init_btn">Init</button>
<button id="git-add-btn" class="add_btn">Add all files to git tracking</button>
<label for="git-old-file">Old file</label>
<input id="git-old-file" type="file">
<label for="git-new-file">New file</label>
<input id="git-new-file" type="file">
<button id="git-patch-btn" class="patch_btn">Generate Patch</button>
</div>
`;
button(label, action, disabled = false) {
const button = this.create('button', { type: 'button', disabled }, label);
button.dataset.gitAction = action;
return button;
}

render() {
if (!this.containerEl || !this.containerEl.ownerDocument) return;
const panel = this.create('section', { className: 'git-panel' });
panel.append(this.create('h2', { className: 'git-title' }, 'Source Control'));
const status = this.create('p', { className: `git-message git-message--${this.state.messageType}`, role: this.state.messageType === 'error' ? 'alert' : 'status' }, this.state.message);
panel.append(status);

if (this.state.mode === 'non-repository') {
panel.append(this.button('Init repository', 'init'));
} else if (this.state.mode === 'repository' || this.state.mode === 'loading' || this.state.mode === 'error') {
const busy = this.state.mode === 'loading' || this.state.messageType === 'loading';
const toolbar = this.create('div', { className: 'git-actions' });
toolbar.append(this.button('Refresh', 'refresh', busy), this.button('Stage all', 'stage', busy || !this.state.entries.length), this.button('Pull', 'pull', busy), this.button('Push', 'push', busy));
panel.append(toolbar);
const input = this.create('input', { id: 'git-commit-message', className: 'git-commit-input', type: 'text', placeholder: 'Commit message', disabled: busy || !this.state.entries.length });
input.setAttribute('aria-label', 'Commit message');
panel.append(input, this.button('Commit', 'commit', true));
const list = this.create('ul', { className: 'git-status-list' });
list.setAttribute('aria-label', 'Changed files');
if (!this.state.entries.length) list.append(this.create('li', { className: 'git-clean' }, 'No changes'));
for (const entry of this.state.entries) {
const item = this.create('li', { className: 'git-status-entry' });
item.append(this.create('span', { className: 'git-status-code' }, entry.code), this.create('span', { className: 'git-status-file', title: entry.file }, entry.file));
list.append(item);
}
panel.append(list);
}
this.containerEl.replaceChildren(panel);
this.renderActionState();
}

renderActionState() {
const input = this.containerEl?.querySelector?.('#git-commit-message');
const commit = this.containerEl?.querySelector?.('[data-git-action="commit"]');
if (input && commit) commit.disabled = input.disabled || !input.value.trim();
}
}

module.exports = { GitUi };
Loading
Loading