guito 0.5.3 → 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,47 @@
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
+
34
+ ## [0.5.4] - 2026-09-06
35
+
36
+ ### Changed
37
+
38
+ - The commit table now renders only the visible rows with virtual scrolling, so scrolling stays smooth no matter how much history is loaded.
39
+ - The commit graph is drawn as a viewport-sized overlay that tracks the scroll position, replacing the full-height SVG canvas.
40
+ - Lane assignment for large histories (over 2,000 commits) runs in a web worker, keeping the interface responsive after "Load all" on repositories with 10k+ commits.
41
+ - The commit list payload omits commit bodies, roughly halving its size; the detail pane fetches the body on demand when a commit is opened.
42
+ - Commit body search now runs on the server, preserving full-history body matching with the trimmed list payload.
43
+ - Working-tree actions (discard, reset, clean) refresh only the working-tree status instead of refetching the commit history, branches, and repository info.
44
+ - Refreshing or searching aborts superseded in-flight requests instead of letting stale responses race newer ones.
45
+
5
46
  ## [0.5.3] - 2026-09-06
6
47
 
7
48
  ### 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) => {
@@ -147,13 +156,15 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
147
156
  const skip = Number.parseInt(query.skip ?? '', 10);
148
157
  // Build simple-git options conditionally: an explicit key with an
149
158
  // undefined value would still be emitted as a bare command flag.
159
+ // The commit body is intentionally omitted: it roughly doubles the
160
+ // payload and is only needed when a commit is opened, so it is served
161
+ // on demand by /api/commit/detail instead.
150
162
  const options = {
151
163
  format: {
152
164
  hash: '%H',
153
165
  date: '%aI',
154
166
  message: '%s',
155
167
  refs: '%D',
156
- body: '%b',
157
168
  author_name: '%aN',
158
169
  author_email: '%aE',
159
170
  parents: '%P',
@@ -188,10 +199,42 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
188
199
  return resp.status(400).type('application/json').send({ error: err.message });
189
200
  }
190
201
  });
202
+ // Full-text commit search. The list payload omits commit bodies, so body
203
+ // matching runs here (git --grep covers subject and body together) and the
204
+ // client unions the resulting hashes with its local subject/author/hash
205
+ // matches over the loaded history.
206
+ app.get('/api/commits/search', async (req, resp) => {
207
+ const query = String((req.query ?? {}).query ?? '').trim();
208
+ if (!query) {
209
+ return resp.type('application/json').send({ hashes: [] });
210
+ }
211
+ try {
212
+ const raw = await git.raw([
213
+ 'log',
214
+ '--all',
215
+ '-i',
216
+ '--fixed-strings',
217
+ `--grep=${query}`,
218
+ '--format=%H',
219
+ ]);
220
+ return resp.type('application/json').send({
221
+ hashes: raw
222
+ .split('\n')
223
+ .map((hash) => hash.trim())
224
+ .filter(Boolean),
225
+ });
226
+ }
227
+ catch (err) {
228
+ if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? '')) {
229
+ return resp.type('application/json').send({ hashes: [] });
230
+ }
231
+ return resp.status(400).type('application/json').send({ error: err.message });
232
+ }
233
+ });
191
234
  app.post('/api/commit', async (req, resp) => {
192
235
  try {
193
- const { message, description } = req.body;
194
- await git.commit([message, ...(description ? [description] : [])], { '--allow-empty': null });
236
+ const { message, description } = req.body ?? {};
237
+ await mutate(() => working.commit(message, description));
195
238
  return resp.type('application/json').send({ success: true });
196
239
  }
197
240
  catch (err) {
@@ -241,7 +284,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
241
284
  });
242
285
  app.post('/api/reset-commit', async (req, resp) => {
243
286
  try {
244
- 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]);
245
289
  return resp.type('application/json').send({ success: true });
246
290
  }
247
291
  catch (err) {
@@ -260,10 +304,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
260
304
  });
261
305
  app.post('/api/stage', async (req, resp) => {
262
306
  try {
263
- const { files } = req.body;
264
- if (Array.isArray(files) && files.length > 0) {
265
- await git.add(files);
266
- }
307
+ await mutate(() => working.stage(req.body?.files));
267
308
  return resp.type('application/json').send({ success: true });
268
309
  }
269
310
  catch (err) {
@@ -272,10 +313,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
272
313
  });
273
314
  app.post('/api/unstage', async (req, resp) => {
274
315
  try {
275
- const { files } = req.body;
276
- if (Array.isArray(files) && files.length > 0) {
277
- await git.reset(['HEAD', ...files]);
278
- }
316
+ await mutate(() => working.unstage(req.body?.files));
279
317
  return resp.type('application/json').send({ success: true });
280
318
  }
281
319
  catch (err) {
@@ -441,8 +479,22 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
441
479
  });
442
480
  app.post('/api/stash/save', async (req, resp) => {
443
481
  try {
444
- const { message } = req.body;
445
- 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);
446
498
  return resp.type('application/json').send({ success: true });
447
499
  }
448
500
  catch (err) {
@@ -524,6 +576,19 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
524
576
  return resp.status(400).type('application/json').send({ error: err.message });
525
577
  }
526
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
+ });
527
592
  // ==================== Remote Operations ====================
528
593
  app.get('/api/fetch', async (_req, resp) => {
529
594
  try {
@@ -574,6 +639,41 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
574
639
  return resp.status(400).type('application/json').send({ error: err.message });
575
640
  }
576
641
  });
642
+ // ==================== Commit Detail ====================
643
+ app.post('/api/commit/detail', async (req, resp) => {
644
+ try {
645
+ const { hash } = req.body;
646
+ if (!hash) {
647
+ return resp.status(400).type('application/json').send({ error: 'hash required' });
648
+ }
649
+ // Fields are NUL-separated; %b may span lines and contain anything
650
+ // except NUL, so it cannot be split naively by newline.
651
+ const raw = await git.raw([
652
+ 'show',
653
+ '-s',
654
+ '--format=%H%x00%aI%x00%s%x00%b%x00%aN%x00%aE%x00%P',
655
+ hash,
656
+ ]);
657
+ const [fullHash, date, message, body, authorName, authorEmail, parents] = raw
658
+ .trimEnd()
659
+ .split('\u0000');
660
+ return resp.type('application/json').send({
661
+ hash: fullHash,
662
+ date,
663
+ message,
664
+ refs: '',
665
+ body: body ?? '',
666
+ author_name: authorName,
667
+ author_email: authorEmail,
668
+ parents: String(parents ?? '')
669
+ .split(' ')
670
+ .filter(Boolean),
671
+ });
672
+ }
673
+ catch (err) {
674
+ return resp.status(400).type('application/json').send({ error: err.message });
675
+ }
676
+ });
577
677
  // ==================== Diff Viewer ====================
578
678
  app.post('/api/commit/diff', async (req, resp) => {
579
679
  try {
@@ -600,57 +700,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
600
700
  // ==================== Working Changes ====================
601
701
  app.get('/api/working-changes', async (_req, resp) => {
602
702
  try {
603
- const root = await repoRoot();
604
- // Combined diff of the working tree + index against HEAD.
605
- const rawDiff = await git.raw(['diff', 'HEAD', '--no-color', '--find-renames']);
606
- const files = parseUnifiedDiff(rawDiff);
607
- // Untracked files are not part of `git diff` — add them as new files.
608
- const status = await git.status();
609
- for (const file of status.not_added ?? []) {
610
- let isBinary = false;
611
- let content = '';
612
- try {
613
- const buffer = await readFile(join(root, file));
614
- if (buffer.includes(0)) {
615
- isBinary = true;
616
- }
617
- else {
618
- content = buffer.toString('utf8');
619
- }
620
- }
621
- catch {
622
- content = '';
623
- }
624
- if (isBinary) {
625
- files.push({
626
- path: file,
627
- oldPath: '',
628
- status: 'binary',
629
- lines: [],
630
- additions: 0,
631
- deletions: 0,
632
- });
633
- }
634
- else {
635
- const lines = content.length > 0 ? content.split('\n') : [];
636
- files.push({
637
- path: file,
638
- oldPath: '',
639
- status: 'added',
640
- lines: lines.map((text, index) => ({ type: 'add', newLine: index + 1, text })),
641
- additions: lines.length,
642
- deletions: 0,
643
- });
644
- }
645
- }
646
- const stagedRaw = await git.raw(['diff', '--name-only', '--cached']);
647
- const unstagedRaw = await git.raw(['diff', '--name-only']);
648
- return resp.type('application/json').send({
649
- files,
650
- staged: stagedRaw.split('\n').filter(Boolean),
651
- unstaged: unstagedRaw.split('\n').filter(Boolean),
652
- untracked: status.not_added ?? [],
653
- });
703
+ await mutationQueue;
704
+ return resp.type('application/json').send(await working.snapshot());
654
705
  }
655
706
  catch (err) {
656
707
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -686,7 +737,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
686
737
  return resp.type('application/json').send({ content: '' });
687
738
  }
688
739
  try {
689
- const content = await git.raw(['show', `${ref}:${path}`]);
740
+ const content = await git.raw(['show', ref === 'INDEX' ? `:${path}` : `${ref}:${path}`]);
690
741
  if (content.includes('\u0000')) {
691
742
  return resp.type('application/json').send({ binary: true, content: '' });
692
743
  }
@@ -704,7 +755,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
704
755
  // ==================== Discard ====================
705
756
  app.post('/api/discard', async (req, resp) => {
706
757
  try {
707
- const { files } = req.body;
758
+ const { files, mode } = req.body;
708
759
  if (!Array.isArray(files) || files.length === 0) {
709
760
  return resp.status(400).type('application/json').send({ error: 'files required' });
710
761
  }
@@ -714,7 +765,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
714
765
  const tracked = files.filter((file) => !untracked.has(file));
715
766
  const removed = files.filter((file) => untracked.has(file));
716
767
  if (tracked.length > 0) {
717
- 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
+ }
718
782
  }
719
783
  for (const file of removed) {
720
784
  await rm(join(root, file), { force: true, recursive: true });
@@ -300,6 +300,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
300
300
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
301
301
  THE SOFTWARE.
302
302
 
303
+ --------------------------------------------------------------------------------
304
+ Package: @angular/cdk
305
+ License: "MIT"
306
+
307
+ The MIT License
308
+
309
+ Copyright (c) 2025 Google LLC.
310
+
311
+ Permission is hereby granted, free of charge, to any person obtaining a copy
312
+ of this software and associated documentation files (the "Software"), to deal
313
+ in the Software without restriction, including without limitation the rights
314
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
315
+ copies of the Software, and to permit persons to whom the Software is
316
+ furnished to do so, subject to the following conditions:
317
+
318
+ The above copyright notice and this permission notice shall be included in
319
+ all copies or substantial portions of the Software.
320
+
321
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
322
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
323
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
324
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
325
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
326
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
327
+ THE SOFTWARE.
328
+
303
329
  --------------------------------------------------------------------------------
304
330
  Package: zone.js
305
331
  License: "MIT"
@@ -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-A5ZTTMFS.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>