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 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
@@ -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
- .type('application/json')
213
- .send({ hashes: raw.split('\n').map((hash) => hash.trim()).filter(Boolean) });
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 git.commit([message, ...(description ? [description] : [])], { '--allow-empty': null });
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
- await git.raw(['reset', '--hard', req.body.commit]);
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
- const { files } = req.body;
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
- const { files } = req.body;
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
- await git.stash(['save', message || '']);
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.trimEnd().split('\u0000');
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
- const root = await repoRoot();
668
- // Combined diff of the working tree + index against HEAD.
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
- await git.raw(['checkout', 'HEAD', '--', ...tracked]);
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-3QYX4VGI.js" type="module"></script></body>
12
+ <script src="polyfills-5CFQRCPP.js" type="module"></script><script src="main-GMXCXTX7.js" type="module"></script></body>
13
13
  </html>