guito 0.5.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTION.md +6 -0
- package/README.md +8 -1
- package/bin/guito-server.js +71 -71
- package/bin/ui/browser/index.html +1 -1
- package/bin/ui/browser/main-GMXCXTX7.js +6 -0
- package/bin/working-tree.js +141 -0
- package/package.json +4 -2
- package/playwright.config.mjs +9 -0
- package/bin/ui/browser/main-3QYX4VGI.js +0 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Guito are documented in this file. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and this changelog is structured around [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
4
4
|
|
|
5
|
+
## [0.6.0] - 2026-09-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Stage and unstage individual files, selected ranges, or all changes from the Uncommitted changes panel, with Ctrl/Cmd and Shift selection and separate index/working-tree diff previews.
|
|
10
|
+
- Write a commit subject and optional description, then commit staged changes without leaving Guito. Drafts survive panel closure and failed commits during the session.
|
|
11
|
+
- Rename and delete local branches from the branch context menu. Deleting the checked-out branch is disabled, the branches dropdown follows renames, and it falls back to "Show All" when the selected branch is deleted.
|
|
12
|
+
- Navigate commit search results with up/down arrows (or Enter and Shift+Enter in the search box) instead of filtering the list. Matches are highlighted in place, the counter shows the focused match, and navigating to a match outside the loaded history fetches more commits until it can be selected and scrolled into view.
|
|
13
|
+
- Choose the reset type when resetting the current branch to a commit. A dialog offers soft (keep changes staged), mixed (keep changes unstaged), and hard (discard all changes) resets.
|
|
14
|
+
- Add a tag context menu with view details, delete tag, push tag, create archive, and copy tag name actions. Pushing a tag uploads it to the remote through the new `/api/tag/push` endpoint.
|
|
15
|
+
- Clear the commit search with a dedicated button.
|
|
16
|
+
- Persist column widths and the commit message draft across sessions using browser storage.
|
|
17
|
+
- Stash all, staged, or unstaged changes directly from the Uncommitted changes panel. Staged-only stashes keep unstaged work in place, and unstaged stashes keep staged changes staged.
|
|
18
|
+
- Discard selected or all unstaged changes from the Uncommitted changes panel after a confirmation dialog. Discarding unstaged edits preserves staged changes for files with both.
|
|
19
|
+
- Added browser regressions and Git integration coverage for search, table alignment, staging, committing, and branch management.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Remove the per-file stage/unstage buttons from file rows in the Uncommitted changes panel; staging and unstaging now use the selection and bulk actions in each section header.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Keep the table header, uncommitted row, graph, and commit columns aligned while scrolling horizontally or resizing columns.
|
|
28
|
+
- Restore virtual rows after searching or clearing empty results; show loading feedback during search, refresh, history loading, and graph computation.
|
|
29
|
+
- Recover pending graph work when its worker fails, ignore superseded search requests, and allow explicit retries without automatic failure loops.
|
|
30
|
+
- Preserve the last known working status after errors and support initial commits, partial staging, renamed/deleted files, and literal filenames.
|
|
31
|
+
- Reject blank subjects, empty-index commits, and unresolved conflicts, and prevent overlapping UI mutations.
|
|
32
|
+
- Keep commit graph edges connected across viewport boundaries while scrolling, so long merge edges no longer disappear from view.
|
|
33
|
+
|
|
5
34
|
## [0.5.4] - 2026-09-06
|
|
6
35
|
|
|
7
36
|
### Changed
|
package/CONTRIBUTION.md
CHANGED
|
@@ -96,8 +96,14 @@ Run the same core checks used by the release workflow:
|
|
|
96
96
|
npm run check
|
|
97
97
|
npm run build
|
|
98
98
|
npm test
|
|
99
|
+
npx playwright install chromium --only-shell
|
|
100
|
+
npm run test:browser
|
|
99
101
|
```
|
|
100
102
|
|
|
103
|
+
The browser suite uses a disposable repository and authenticated API fixtures. Failure traces are saved under `test-results/`.
|
|
104
|
+
|
|
105
|
+
On Windows, after building, `node tests/browser/vscode-smoke.mjs` also opens an isolated Extension Development Host, stages a fixture file, and commits it through the authenticated webview. Set `GUITO_CODE_EXE` if VS Code is installed outside its default per-user location. The test closes its host and removes its temporary profile and repository afterward.
|
|
106
|
+
|
|
101
107
|
- Add or update tests when changing server routes, authentication, repository selection, or Git behavior.
|
|
102
108
|
- Test UI changes against a disposable repository with branches, merges, remote references, working-tree changes, and untracked files where relevant.
|
|
103
109
|
- Exercise destructive operations only in a disposable repository.
|
package/README.md
CHANGED
|
@@ -9,12 +9,13 @@ The name combines **GUI** and **Git**: **G**u**IT**o. It is also a [Portuguese w
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
11
|
- Visual commit graph with branch and tag references.
|
|
12
|
-
- Commit search by subject, author, or hash.
|
|
12
|
+
- Commit search by subject, body, author, or hash, with loading feedback and retries.
|
|
13
13
|
- Local and remote branch filtering.
|
|
14
14
|
- Commit and working-tree diffs powered by the Monaco editor, with inline and side-by-side layouts.
|
|
15
15
|
- Fetch, pull, pull with rebase, push, and sync actions.
|
|
16
16
|
- Context actions for creating branches and tags, checking out commits, cherry-picking, reverting, merging, rebasing, resetting, and exporting archives.
|
|
17
17
|
- Working-tree actions for stashing changes, resetting tracked changes, and cleaning untracked files.
|
|
18
|
+
- Stage and unstage files with multi-selection, preview staged and unstaged diffs, and commit staged changes.
|
|
18
19
|
- Browser-based CLI and a VS Code extension with local, Remote SSH, Dev Container, and Codespaces support.
|
|
19
20
|
|
|
20
21
|
> [!CAUTION]
|
|
@@ -48,6 +49,12 @@ After installing the extension, open a trusted workspace containing a Git reposi
|
|
|
48
49
|
|
|
49
50
|
In a multi-root workspace, Guito asks which repository to open. Git must be available on the local or remote VS Code extension host.
|
|
50
51
|
|
|
52
|
+
## Stage and commit changes
|
|
53
|
+
|
|
54
|
+
Click **Uncommitted changes** to open the staged and unstaged file lists. Click to select a file, Ctrl/Cmd-click to toggle individual files, or Shift-click to select a range. Use the per-file buttons, selected-file actions, or **Stage all** / **Unstage all**. The separate **Diff** button previews only the changes in that list.
|
|
55
|
+
|
|
56
|
+
Enter a commit message and optional description, then choose **Commit staged changes**. Only staged changes are committed; unstaged edits remain on disk. Commit drafts stay available when you close and reopen the panel during the session.
|
|
57
|
+
|
|
51
58
|
## CLI options
|
|
52
59
|
|
|
53
60
|
```text
|
package/bin/guito-server.js
CHANGED
|
@@ -8,6 +8,7 @@ import fastifyCors from '@fastify/cors';
|
|
|
8
8
|
import fastifyCompress from '@fastify/compress';
|
|
9
9
|
import { simpleGit } from 'simple-git';
|
|
10
10
|
import { promisify } from 'node:util';
|
|
11
|
+
import { workingTree } from './working-tree.js';
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
12
13
|
export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, }) {
|
|
13
14
|
// Initialize server
|
|
@@ -128,6 +129,14 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
128
129
|
files.push(current);
|
|
129
130
|
return files;
|
|
130
131
|
}
|
|
132
|
+
const working = workingTree(git, parseUnifiedDiff);
|
|
133
|
+
// Keep validation and the corresponding index mutation in one operation.
|
|
134
|
+
let mutationQueue = Promise.resolve();
|
|
135
|
+
const mutate = (action) => {
|
|
136
|
+
const result = mutationQueue.then(action);
|
|
137
|
+
mutationQueue = result.catch(() => { });
|
|
138
|
+
return result;
|
|
139
|
+
};
|
|
131
140
|
const repoRoot = async () => (await git.revparse(['--show-toplevel'])).trim();
|
|
132
141
|
// ==================== Repository ====================
|
|
133
142
|
app.get('/api/repo', async (_req, resp) => {
|
|
@@ -208,9 +217,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
208
217
|
`--grep=${query}`,
|
|
209
218
|
'--format=%H',
|
|
210
219
|
]);
|
|
211
|
-
return resp
|
|
212
|
-
|
|
213
|
-
|
|
220
|
+
return resp.type('application/json').send({
|
|
221
|
+
hashes: raw
|
|
222
|
+
.split('\n')
|
|
223
|
+
.map((hash) => hash.trim())
|
|
224
|
+
.filter(Boolean),
|
|
225
|
+
});
|
|
214
226
|
}
|
|
215
227
|
catch (err) {
|
|
216
228
|
if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? '')) {
|
|
@@ -221,8 +233,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
221
233
|
});
|
|
222
234
|
app.post('/api/commit', async (req, resp) => {
|
|
223
235
|
try {
|
|
224
|
-
const { message, description } = req.body;
|
|
225
|
-
await
|
|
236
|
+
const { message, description } = req.body ?? {};
|
|
237
|
+
await mutate(() => working.commit(message, description));
|
|
226
238
|
return resp.type('application/json').send({ success: true });
|
|
227
239
|
}
|
|
228
240
|
catch (err) {
|
|
@@ -272,7 +284,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
272
284
|
});
|
|
273
285
|
app.post('/api/reset-commit', async (req, resp) => {
|
|
274
286
|
try {
|
|
275
|
-
|
|
287
|
+
const mode = ['soft', 'mixed', 'hard'].includes(req.body?.mode) ? req.body.mode : 'hard';
|
|
288
|
+
await git.raw(['reset', `--${mode}`, req.body.commit]);
|
|
276
289
|
return resp.type('application/json').send({ success: true });
|
|
277
290
|
}
|
|
278
291
|
catch (err) {
|
|
@@ -291,10 +304,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
291
304
|
});
|
|
292
305
|
app.post('/api/stage', async (req, resp) => {
|
|
293
306
|
try {
|
|
294
|
-
|
|
295
|
-
if (Array.isArray(files) && files.length > 0) {
|
|
296
|
-
await git.add(files);
|
|
297
|
-
}
|
|
307
|
+
await mutate(() => working.stage(req.body?.files));
|
|
298
308
|
return resp.type('application/json').send({ success: true });
|
|
299
309
|
}
|
|
300
310
|
catch (err) {
|
|
@@ -303,10 +313,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
303
313
|
});
|
|
304
314
|
app.post('/api/unstage', async (req, resp) => {
|
|
305
315
|
try {
|
|
306
|
-
|
|
307
|
-
if (Array.isArray(files) && files.length > 0) {
|
|
308
|
-
await git.reset(['HEAD', ...files]);
|
|
309
|
-
}
|
|
316
|
+
await mutate(() => working.unstage(req.body?.files));
|
|
310
317
|
return resp.type('application/json').send({ success: true });
|
|
311
318
|
}
|
|
312
319
|
catch (err) {
|
|
@@ -472,8 +479,22 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
472
479
|
});
|
|
473
480
|
app.post('/api/stash/save', async (req, resp) => {
|
|
474
481
|
try {
|
|
475
|
-
const { message } = req.body;
|
|
476
|
-
|
|
482
|
+
const { message, scope } = req.body ?? {};
|
|
483
|
+
// 'staged' → index only (--staged, git ≥ 2.35); 'unstaged' → working tree only
|
|
484
|
+
// (--keep-index leaves staged changes staged); 'all' → index + working tree.
|
|
485
|
+
// Untracked files ride along for 'all'/'unstaged' since the panel lists them as unstaged.
|
|
486
|
+
const args = ['push'];
|
|
487
|
+
if (scope === 'staged') {
|
|
488
|
+
args.push('--staged');
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
if (scope === 'unstaged')
|
|
492
|
+
args.push('--keep-index');
|
|
493
|
+
args.push('-u');
|
|
494
|
+
}
|
|
495
|
+
if (message)
|
|
496
|
+
args.push('-m', message);
|
|
497
|
+
await git.stash(args);
|
|
477
498
|
return resp.type('application/json').send({ success: true });
|
|
478
499
|
}
|
|
479
500
|
catch (err) {
|
|
@@ -555,6 +576,19 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
555
576
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
556
577
|
}
|
|
557
578
|
});
|
|
579
|
+
app.post('/api/tag/push', async (req, resp) => {
|
|
580
|
+
try {
|
|
581
|
+
const { name, remote } = req.body;
|
|
582
|
+
if (!name) {
|
|
583
|
+
return resp.status(400).type('application/json').send({ error: 'name required' });
|
|
584
|
+
}
|
|
585
|
+
await git.push(remote || 'origin', `refs/tags/${name}`);
|
|
586
|
+
return resp.type('application/json').send({ success: true });
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
590
|
+
}
|
|
591
|
+
});
|
|
558
592
|
// ==================== Remote Operations ====================
|
|
559
593
|
app.get('/api/fetch', async (_req, resp) => {
|
|
560
594
|
try {
|
|
@@ -620,7 +654,9 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
620
654
|
'--format=%H%x00%aI%x00%s%x00%b%x00%aN%x00%aE%x00%P',
|
|
621
655
|
hash,
|
|
622
656
|
]);
|
|
623
|
-
const [fullHash, date, message, body, authorName, authorEmail, parents] = raw
|
|
657
|
+
const [fullHash, date, message, body, authorName, authorEmail, parents] = raw
|
|
658
|
+
.trimEnd()
|
|
659
|
+
.split('\u0000');
|
|
624
660
|
return resp.type('application/json').send({
|
|
625
661
|
hash: fullHash,
|
|
626
662
|
date,
|
|
@@ -664,57 +700,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
664
700
|
// ==================== Working Changes ====================
|
|
665
701
|
app.get('/api/working-changes', async (_req, resp) => {
|
|
666
702
|
try {
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const rawDiff = await git.raw(['diff', 'HEAD', '--no-color', '--find-renames']);
|
|
670
|
-
const files = parseUnifiedDiff(rawDiff);
|
|
671
|
-
// Untracked files are not part of `git diff` — add them as new files.
|
|
672
|
-
const status = await git.status();
|
|
673
|
-
for (const file of status.not_added ?? []) {
|
|
674
|
-
let isBinary = false;
|
|
675
|
-
let content = '';
|
|
676
|
-
try {
|
|
677
|
-
const buffer = await readFile(join(root, file));
|
|
678
|
-
if (buffer.includes(0)) {
|
|
679
|
-
isBinary = true;
|
|
680
|
-
}
|
|
681
|
-
else {
|
|
682
|
-
content = buffer.toString('utf8');
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
catch {
|
|
686
|
-
content = '';
|
|
687
|
-
}
|
|
688
|
-
if (isBinary) {
|
|
689
|
-
files.push({
|
|
690
|
-
path: file,
|
|
691
|
-
oldPath: '',
|
|
692
|
-
status: 'binary',
|
|
693
|
-
lines: [],
|
|
694
|
-
additions: 0,
|
|
695
|
-
deletions: 0,
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
else {
|
|
699
|
-
const lines = content.length > 0 ? content.split('\n') : [];
|
|
700
|
-
files.push({
|
|
701
|
-
path: file,
|
|
702
|
-
oldPath: '',
|
|
703
|
-
status: 'added',
|
|
704
|
-
lines: lines.map((text, index) => ({ type: 'add', newLine: index + 1, text })),
|
|
705
|
-
additions: lines.length,
|
|
706
|
-
deletions: 0,
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
const stagedRaw = await git.raw(['diff', '--name-only', '--cached']);
|
|
711
|
-
const unstagedRaw = await git.raw(['diff', '--name-only']);
|
|
712
|
-
return resp.type('application/json').send({
|
|
713
|
-
files,
|
|
714
|
-
staged: stagedRaw.split('\n').filter(Boolean),
|
|
715
|
-
unstaged: unstagedRaw.split('\n').filter(Boolean),
|
|
716
|
-
untracked: status.not_added ?? [],
|
|
717
|
-
});
|
|
703
|
+
await mutationQueue;
|
|
704
|
+
return resp.type('application/json').send(await working.snapshot());
|
|
718
705
|
}
|
|
719
706
|
catch (err) {
|
|
720
707
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -750,7 +737,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
750
737
|
return resp.type('application/json').send({ content: '' });
|
|
751
738
|
}
|
|
752
739
|
try {
|
|
753
|
-
const content = await git.raw(['show', `${ref}:${path}`]);
|
|
740
|
+
const content = await git.raw(['show', ref === 'INDEX' ? `:${path}` : `${ref}:${path}`]);
|
|
754
741
|
if (content.includes('\u0000')) {
|
|
755
742
|
return resp.type('application/json').send({ binary: true, content: '' });
|
|
756
743
|
}
|
|
@@ -768,7 +755,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
768
755
|
// ==================== Discard ====================
|
|
769
756
|
app.post('/api/discard', async (req, resp) => {
|
|
770
757
|
try {
|
|
771
|
-
const { files } = req.body;
|
|
758
|
+
const { files, mode } = req.body;
|
|
772
759
|
if (!Array.isArray(files) || files.length === 0) {
|
|
773
760
|
return resp.status(400).type('application/json').send({ error: 'files required' });
|
|
774
761
|
}
|
|
@@ -778,7 +765,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
778
765
|
const tracked = files.filter((file) => !untracked.has(file));
|
|
779
766
|
const removed = files.filter((file) => untracked.has(file));
|
|
780
767
|
if (tracked.length > 0) {
|
|
781
|
-
|
|
768
|
+
if (mode === 'unstaged') {
|
|
769
|
+
// Restore from the index so staged changes survive; unmerged (conflicted)
|
|
770
|
+
// paths cannot be restored from the index and fall back to HEAD.
|
|
771
|
+
const conflicted = new Set(status.conflicted ?? []);
|
|
772
|
+
const restorable = tracked.filter((file) => !conflicted.has(file));
|
|
773
|
+
const unmerged = tracked.filter((file) => conflicted.has(file));
|
|
774
|
+
if (restorable.length > 0)
|
|
775
|
+
await git.raw(['checkout', '--', ...restorable]);
|
|
776
|
+
if (unmerged.length > 0)
|
|
777
|
+
await git.raw(['checkout', 'HEAD', '--', ...unmerged]);
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
await git.raw(['checkout', 'HEAD', '--', ...tracked]);
|
|
781
|
+
}
|
|
782
782
|
}
|
|
783
783
|
for (const file of removed) {
|
|
784
784
|
await rm(join(root, file), { force: true, recursive: true });
|
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
<style>:root{--bg:#1e1e1e;--bg-alt:#232323;--bg-hover:#2a2d2e;--border:#3c3c3c;--text:#cccccc;--text-bright:#e8e8e8;--text-dim:#9d9d9d;--accent:#3794ff;--red:#f14c4c;--mono:ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace}*{box-sizing:border-box}html,body{height:100%;margin:0}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:13px;color:var(--text);background:var(--bg);overflow:hidden}</style><link rel="stylesheet" href="styles-I7K2YZTW.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles-I7K2YZTW.css"></noscript></head>
|
|
10
10
|
<body>
|
|
11
11
|
<app-root></app-root>
|
|
12
|
-
<script src="polyfills-5CFQRCPP.js" type="module"></script><script src="main-
|
|
12
|
+
<script src="polyfills-5CFQRCPP.js" type="module"></script><script src="main-GMXCXTX7.js" type="module"></script></body>
|
|
13
13
|
</html>
|