diff --git a/package.json b/package.json index 8a1c2932..e80dea08 100644 --- a/package.json +++ b/package.json @@ -409,6 +409,11 @@ "minimum": 1, "markdownDescription": "Number of additional commits fetched each time the **Load more commits** button is clicked." }, + "gitGraphPlus.autoLoadHistory": { + "type": "boolean", + "default": false, + "markdownDescription": "Automatically load the next chunk of history when you scroll near the bottom of the graph, instead of clicking **Load more commits** each time. The button remains available." + }, "gitGraphPlus.interactiveRebase.mode": { "type": "string", "enum": ["ui", "classic"], diff --git a/src/panels/MainPanel.ts b/src/panels/MainPanel.ts index 5869905d..cbc083fb 100644 --- a/src/panels/MainPanel.ts +++ b/src/panels/MainPanel.ts @@ -5,7 +5,7 @@ import { GitService, GitError } from '../git/git-service'; import { formatGitError, isAuthFailure, transportFromRemoteUrl } from '../git/git-error-formatter'; import { splitUpstreamRef } from '../git/git-parser'; import { samePath } from '../utils/path'; -import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readInteractiveRebaseMode } from '../utils/config'; +import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readAutoLoadHistory, readInteractiveRebaseMode } from '../utils/config'; import { buildClassicRebaseCommand } from '../git/classic-rebase'; import { buildFullGraph } from '../git/git-graph-builder'; import { compileBranchColorRules, makeBranchColorResolver } from '../git/branch-color-resolver'; @@ -226,6 +226,9 @@ export class MainPanel { if (e.affectsConfiguration('gitGraphPlus.loadMoreCommitCount')) { this.post({ type: 'setLoadMoreCount', payload: { count: readLoadMoreCommitCount() } }); } + if (e.affectsConfiguration('gitGraphPlus.autoLoadHistory')) { + this.post({ type: 'setAutoLoadHistory', payload: { enabled: readAutoLoadHistory() } }); + } if (e.affectsConfiguration('gitGraphPlus.branchColors')) { this.refreshAll(); } @@ -255,6 +258,7 @@ export class MainPanel { this.post({ type: 'setBadgeBarThickness', payload: { width: this.readBadgeBarWidth() } }); this.post({ type: 'setGraphColors', payload: { colors: this.readGraphColors() } }); this.post({ type: 'setLoadMoreCount', payload: { count: readLoadMoreCommitCount() } }); + this.post({ type: 'setAutoLoadHistory', payload: { enabled: readAutoLoadHistory() } }); this.post({ type: 'setInteractiveRebaseMode', payload: { mode: readInteractiveRebaseMode() } }); void this.postCommitLinkRules(); diff --git a/src/panels/__tests__/MainPanel.test.ts b/src/panels/__tests__/MainPanel.test.ts index 76051ee3..26feae9a 100644 --- a/src/panels/__tests__/MainPanel.test.ts +++ b/src/panels/__tests__/MainPanel.test.ts @@ -148,6 +148,10 @@ describe('MainPanel construction', () => { expect(H.panel!.webview).toBeDefined(); expect(postedOfType('setLocale').length).toBeGreaterThan(0); }); + + it('posts the auto-load-history setting on init (default off)', () => { + expect(postedOfType('setAutoLoadHistory').at(-1)?.payload).toEqual({ enabled: false }); + }); }); describe('MainPanel message routing', () => { diff --git a/src/utils/__tests__/config.test.ts b/src/utils/__tests__/config.test.ts index 63667d34..941d90fd 100644 --- a/src/utils/__tests__/config.test.ts +++ b/src/utils/__tests__/config.test.ts @@ -13,7 +13,7 @@ vi.mock('vscode', () => ({ }, })); -import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount } from '../config'; +import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readAutoLoadHistory } from '../config'; describe('readTimeoutMs', () => { // Back-compat alias so the existing timeout cases below read naturally. @@ -101,3 +101,26 @@ describe('readLoadMoreCommitCount', () => { expect(readLoadMoreCommitCount()).toBe(50); }); }); + +describe('readAutoLoadHistory', () => { + beforeEach(() => { h.values = {}; }); + + it('defaults to false when unset (manual "Load more" button preserved)', () => { + expect(readAutoLoadHistory()).toBe(false); + }); + + it('returns true when explicitly enabled', () => { + h.values.autoLoadHistory = true; + expect(readAutoLoadHistory()).toBe(true); + }); + + it('returns false when explicitly disabled', () => { + h.values.autoLoadHistory = false; + expect(readAutoLoadHistory()).toBe(false); + }); + + it('falls back to false for a non-boolean value', () => { + h.values.autoLoadHistory = 'yes'; + expect(readAutoLoadHistory()).toBe(false); + }); +}); diff --git a/src/utils/config.ts b/src/utils/config.ts index fa8fabed..ed5e7634 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -15,6 +15,8 @@ export function readTimeoutMs(): number { export const DEFAULT_INITIAL_COMMIT_COUNT = 200; /** Default number of extra commits fetched each time "Load more" is clicked. */ export const DEFAULT_LOAD_MORE_COMMIT_COUNT = 50; +/** Default for `gitGraphPlus.autoLoadHistory`: off, so "Load more" stays manual. */ +export const DEFAULT_AUTO_LOAD_HISTORY = false; function readPositiveIntSetting(key: string, fallback: number): number { const raw = vscode.workspace.getConfiguration('gitGraphPlus').get(key, fallback); @@ -37,6 +39,18 @@ export function readLoadMoreCommitCount(): number { return readPositiveIntSetting('loadMoreCommitCount', DEFAULT_LOAD_MORE_COMMIT_COUNT); } +/** + * Reads `gitGraphPlus.autoLoadHistory` — whether scrolling near the bottom of + * the graph automatically fetches the next chunk (issue #61). Falls back to + * false when unset, so the default behaviour keeps the "Load more" button + * manual. + */ +export function readAutoLoadHistory(): boolean { + return vscode.workspace + .getConfiguration('gitGraphPlus') + .get('autoLoadHistory', DEFAULT_AUTO_LOAD_HISTORY) === true; +} + /** * Reads `gitGraphPlus.interactiveRebase.mode` — whether interactive rebase * opens the GUI editor (`ui`, default) or runs classic `git rebase -i` in the diff --git a/src/utils/message-bus.ts b/src/utils/message-bus.ts index deec2ab7..ff26db7d 100644 --- a/src/utils/message-bus.ts +++ b/src/utils/message-bus.ts @@ -158,6 +158,7 @@ export type ExtensionMessage = | { type: 'setInteractiveRebaseMode'; payload: { mode: 'ui' | 'classic' } } | { type: 'setDefaults'; payload: ModalDefaults } | { type: 'setLoadMoreCount'; payload: { count: number } } + | { type: 'setAutoLoadHistory'; payload: { enabled: boolean } } | { type: 'setBadgeBarThickness'; payload: { width: number } } | { type: 'setGraphColors'; payload: { colors: string[] } } | { type: 'setCommitLinkRules'; payload: { rules: LinkRule[] } } diff --git a/webview-ui/src/App.svelte b/webview-ui/src/App.svelte index c649320f..46528a40 100644 --- a/webview-ui/src/App.svelte +++ b/webview-ui/src/App.svelte @@ -115,6 +115,9 @@ import AmendModal from './components/modals/AmendModal.svelte'; case 'setLoadMoreCount': uiStore.loadMoreCount = msg.payload.count; break; + case 'setAutoLoadHistory': + uiStore.autoLoadHistory = msg.payload.enabled; + break; case 'setInteractiveRebaseMode': uiStore.interactiveRebaseMode = msg.payload.mode; break; diff --git a/webview-ui/src/__tests__/App.test.ts b/webview-ui/src/__tests__/App.test.ts index 69b013b7..16383739 100644 --- a/webview-ui/src/__tests__/App.test.ts +++ b/webview-ui/src/__tests__/App.test.ts @@ -30,6 +30,7 @@ function resetStores() { uiStore.activeRepo = ''; uiStore.operating = null; uiStore.badgeBarWidth = 4; + uiStore.autoLoadHistory = false; uiStore.setError(null); // modalStore is a singleton across tests; one stuck open modal will render // through every subsequent App mount and break unrelated assertions. @@ -103,6 +104,18 @@ describe('App — message handling', () => { }); }); + it('setAutoLoadHistory updates uiStore.autoLoadHistory', async () => { + render(App); + postMsg('setAutoLoadHistory', { enabled: true }); + await waitFor(() => { + expect(uiStore.autoLoadHistory).toBe(true); + }); + postMsg('setAutoLoadHistory', { enabled: false }); + await waitFor(() => { + expect(uiStore.autoLoadHistory).toBe(false); + }); + }); + it('repoList populates uiStore.repos and activeRepo', async () => { render(App); postMsg('repoList', { diff --git a/webview-ui/src/components/graph/CommitGraph.svelte b/webview-ui/src/components/graph/CommitGraph.svelte index aa5d2742..5106b50d 100644 --- a/webview-ui/src/components/graph/CommitGraph.svelte +++ b/webview-ui/src/components/graph/CommitGraph.svelte @@ -32,6 +32,7 @@ import type { DirtyPayload } from '../../lib/utils/dirty-payload'; import { resolveDrop } from '../../lib/utils/dragDrop'; import { computeNavigationTarget, computeScrollTop, computeJumpTarget, isRowOffscreen, type ScrollAlign } from '../../lib/graph-navigation'; + import { isNearBottom } from '../../lib/utils/auto-load'; import LinkifiedText from '../common/LinkifiedText.svelte'; import { dispatchInteractiveRebase } from '../../lib/interactive-rebase'; @@ -541,6 +542,28 @@ return col * X_SCALE; } + // Same action as the "Load more commits" button, shared so auto-load sends an + // identical request. The guards make it a no-op when there is nothing to load + // or a request is already in flight; `loadingMore` is cleared by `setData` + // when the response arrives, so it doubles as the in-flight flag. + function loadMore() { + if (!commitStore.hasMore || commitStore.loadingMore) return; + commitStore.setLoadingMore(true); + vscode.postMessage({ type: 'getLog', payload: { limit: commitStore.currentLimit + uiStore.loadMoreCount } }); + } + + // Issue #61: with the opt-in setting on, fetch the next chunk as soon as the + // user scrolls near the bottom. The check is O(1) per scroll event and always + // a no-op when the setting is off, so the default stays button-only. Skipped + // for search results (a fixed, non-paginated set) and while the graph is + // still loading its first page. + function maybeAutoLoadMore() { + if (!container || !uiStore.autoLoadHistory) return; + if (!commitStore.hasMore || commitStore.loadingMore || commitStore.loading) return; + if (isSearchActive) return; + if (isNearBottom(container.scrollTop, container.clientHeight, container.scrollHeight)) loadMore(); + } + // Coalesce scroll events into one update per animation frame. High-refresh // displays fire scroll 100+ times/sec; without this every event synchronously // pushed a new scrollTop, re-running the headOffscreen effect and the visible @@ -548,6 +571,7 @@ // screen can repaint. let scrollRaf: number | null = null; function handleScroll() { + maybeAutoLoadMore(); if (scrollRaf !== null) return; scrollRaf = requestAnimationFrame(() => { scrollRaf = null; @@ -1698,10 +1722,7 @@