-
Notifications
You must be signed in to change notification settings - Fork 1
Feature/git sidebar #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
b5ae69e
Add Basic git command handlers.
gtref dd5da07
Add aditional git commands support and git switch command stub. Also …
gtref a54a0f9
Potential fix for pull request finding 'CodeQL / Incomplete string es…
gtref 12f2a9d
Add some basic stubs.
gtref f25e460
Add eslint.
gtref 9d85282
Potential fix for pull request finding 'CodeQL / Incomplete string es…
gtref bcdb7d2
Prevent shell injection in Git commit messages
coderabbitai[bot] c712078
Add Statis Stup and edit package.json
gtref c86558c
Merge branch 'feature/Git_Sidebar' of https://github.com/gtref/satisc…
gtref 7d542e6
Add initial git ui setup and update website to reflect git integration
gtref d4961fa
Fix Git sidebar command safety and lint errors
coderabbitai[bot] d11368d
Update labler workflow.
gtref f74f03c
Merge branch 'feature/Git_Sidebar' of https://github.com/gtref/satisc…
gtref 6efddaf
Finnish basic git ui sidebar implamentation
gtref 3f9d9a1
Add basic patch file gen to git sidepanel
gtref 1df8890
Connect Git sidebar controls and fix patch generation
coderabbitai[bot] fcec02a
📝 CodeRabbit Chat: Integrate Git Sidebar with Main-Process Workspace …
coderabbitai[bot] 97614d0
Merge pull request #51 from gtref/coderabbitai/chat/1df8890
gtref File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,3 +7,6 @@ config: | |
|
|
||
| theme: | ||
| - "themes/**" | ||
|
|
||
| UI: | ||
| - "UI/**/**" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| class GitManager { | ||
| constructor(options = {}) { | ||
| this.api = options.api; | ||
| this.workspace = options.workspace || null; | ||
| } | ||
|
|
||
| setWorkspace(workspace) { | ||
| this.workspace = workspace || null; | ||
| } | ||
|
|
||
| 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 init() { | ||
| return this.run('init'); | ||
| } | ||
|
|
||
| async addAll() { | ||
| return this.run('addAll'); | ||
| } | ||
|
|
||
| async commit(message) { | ||
| return this.run('commit', message); | ||
| } | ||
|
|
||
| async status() { | ||
| return this.run('status'); | ||
| } | ||
|
|
||
| async push() { | ||
| return this.run('push'); | ||
| } | ||
|
|
||
| async pull() { | ||
| return this.run('pull'); | ||
| } | ||
| } | ||
|
|
||
| module.exports = GitManager; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| class StatusStub { | ||
| static parse(raw) { | ||
| if (!raw || !raw.trim()) { | ||
| return []; | ||
| } | ||
|
|
||
| return raw.split(/\r?\n/).filter(Boolean).map(line => { | ||
| const code = line.slice(0, 2); | ||
| const file = line.slice(2).trim(); | ||
| return { code, file }; | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| class BranchStub { | ||
| static parse(raw) { | ||
| const lines = raw.split("\n").filter(Boolean); | ||
|
|
||
| let current = null; | ||
| const branches = []; | ||
|
|
||
| for (const line of lines) { | ||
| if (line.startsWith("*")) { | ||
| current = line.replace(/^\*\s*/, "").trim(); | ||
| branches.push(current); | ||
| } else { | ||
| branches.push(line.trim()); | ||
| } | ||
| } | ||
| return { current, branches }; | ||
| } | ||
| } | ||
|
|
||
| class LogStub { | ||
| static parse(raw) { | ||
| if (!raw.trim()) return []; | ||
|
|
||
| return raw.split("\n").filter(Boolean).map(line => { | ||
| const [hash, author, message, date] = line.split("|"); | ||
| return { hash, author, message, date }; | ||
| }); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| module.exports = { | ||
| StatusStub, | ||
| BranchStub, | ||
| LogStub | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' } | ||
| ]); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| const GitManager = require('./gitman'); | ||
| const { StatusStub } = require('./gitstub'); | ||
|
|
||
| class GitUi { | ||
| 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'); | ||
| } | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| 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 }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| const assert = require('node:assert/strict'); | ||
| const test = require('node:test'); | ||
| const { GitUi } = require('./gitui'); | ||
|
|
||
| class FakeElement { | ||
| constructor(tagName, document) { this.tagName = tagName; this.ownerDocument = document; this.children = []; this.dataset = {}; this.disabled = false; this.value = ''; } | ||
| append(...children) { this.children.push(...children); } | ||
| replaceChildren(...children) { this.children = children; } | ||
| setAttribute(name, value) { this[name] = value; } | ||
| addEventListener() {} | ||
| querySelector(selector) { | ||
| const matches = (element) => selector.startsWith('#') ? element.id === selector.slice(1) : selector === '[data-git-action="commit"]' && element.dataset.gitAction === 'commit'; | ||
| const visit = (element) => matches(element) ? element : element.children.map(visit).find(Boolean); | ||
| return visit(this); | ||
| } | ||
| get textContent() { return this._text || this.children.map((child) => child.textContent).join(' '); } | ||
| set textContent(value) { this._text = value; } | ||
| } | ||
|
|
||
| function createContainer() { | ||
| const document = { createElement: (tagName) => new FakeElement(tagName, document) }; | ||
| return new FakeElement('div', document); | ||
| } | ||
|
|
||
| function deferred() { | ||
| let resolve; | ||
| const promise = new Promise((done) => { resolve = done; }); | ||
| return { promise, resolve }; | ||
| } | ||
|
|
||
| test('renders empty, loading, repository, success, non-repository, and error states', async () => { | ||
| const container = createContainer(); | ||
| const pending = deferred(); | ||
| const gitman = { setWorkspace() {}, status: () => pending.promise }; | ||
| const ui = new GitUi(container, { gitman }); | ||
| ui.render(); | ||
| assert.match(container.textContent, /Open a folder/); | ||
| const refresh = ui.setWorkspace('/workspace'); | ||
| assert.match(container.textContent, /Loading repository status/); | ||
| pending.resolve(' M changed.js\n?? new.js'); | ||
| await refresh; | ||
| assert.match(container.textContent, /changed\.js/); | ||
| ui.setMessage('Staging files completed.', 'success'); | ||
| assert.match(container.textContent, /completed/); | ||
| gitman.status = async () => { throw new Error('fatal: not a git repository'); }; | ||
| await ui.refresh(); | ||
| assert.match(container.textContent, /Init repository/); | ||
| gitman.status = async () => { throw new Error('permission denied'); }; | ||
| await ui.refresh(); | ||
| assert.match(container.textContent, /permission denied/); | ||
| }); | ||
|
|
||
| test('actions invoke the expected operation and refresh status', async () => { | ||
| let stageCalls = 0; | ||
| let statusCalls = 0; | ||
| const gitman = { setWorkspace() {}, status: async () => { statusCalls++; return ' M file.js'; }, addAll: async () => { stageCalls++; return ''; } }; | ||
| const ui = new GitUi(createContainer(), { gitman }); | ||
| await ui.setWorkspace('/workspace'); | ||
| await ui.runAction('addAll'); | ||
| assert.equal(stageCalls, 1); | ||
| assert.equal(statusCalls, 2); | ||
| assert.equal(ui.state.messageType, 'success'); | ||
| }); | ||
|
|
||
| test('clearing the workspace clears repository state', async () => { | ||
| const ui = new GitUi(createContainer(), { gitman: { setWorkspace() {}, status: async () => '' } }); | ||
| await ui.setWorkspace('/workspace'); | ||
| await ui.setWorkspace(null); | ||
| assert.equal(ui.state.mode, 'empty'); | ||
| assert.deepEqual(ui.state.entries, []); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: gtref/satiscode
Length of output: 8498
🏁 Script executed:
Repository: gtref/satiscode
Length of output: 7384
Discard stale workspace refresh results.
setWorkspace()updatesthis.workspaceandGitManagerbefore awaitingrefresh(). A previousrefresh()can finish later and updatethis.statewith the previous workspace's status or error. The sidebar can then show workspace A while actions run against workspace B.Capture a refresh generation or workspace value, and ignore loading, success, and error updates from stale refreshes.
🤖 Prompt for AI Agents