Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
6 changes: 5 additions & 1 deletion src/panels/MainPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();

Expand Down
4 changes: 4 additions & 0 deletions src/panels/__tests__/MainPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
25 changes: 24 additions & 1 deletion src/utils/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
});
});
14 changes: 14 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>(key, fallback);
Expand All @@ -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<boolean>('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
Expand Down
1 change: 1 addition & 0 deletions src/utils/message-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] } }
Expand Down
3 changes: 3 additions & 0 deletions webview-ui/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions webview-ui/src/__tests__/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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', {
Expand Down
29 changes: 25 additions & 4 deletions webview-ui/src/components/graph/CommitGraph.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -541,13 +542,36 @@
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
// path/link filters (which scan the full arrays) far more often than the
// screen can repaint.
let scrollRaf: number | null = null;
function handleScroll() {
maybeAutoLoadMore();
if (scrollRaf !== null) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = null;
Expand Down Expand Up @@ -1698,10 +1722,7 @@
<button
class="load-more-btn"
disabled={commitStore.loadingMore}
onclick={() => {
commitStore.setLoadingMore(true);
vscode.postMessage({ type: 'getLog', payload: { limit: commitStore.currentLimit + uiStore.loadMoreCount } });
}}
onclick={loadMore}
>
{#if commitStore.loadingMore}
<span class="spinner"></span>
Expand Down
107 changes: 107 additions & 0 deletions webview-ui/src/components/graph/__tests__/CommitGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,15 @@ beforeEach(() => {
commitStore.dots = [];
commitStore.commitLeftMargin = [];
commitStore.loading = false;
commitStore.loadingMore = false;
commitStore.hasMore = false;
commitStore.currentLimit = 0;
commitStore.notGitRepo = false;
branchStore.branches = [];
branchStore.worktrees = [];
uiStore.selectedCommitHash = null;
uiStore.autoLoadHistory = false;
uiStore.loadMoreCount = 50;
modalStore.closeAll();
});

Expand Down Expand Up @@ -418,3 +423,105 @@ describe('CommitGraph signature icon', () => {
uiStore.exitMultiSelect();
});
});

describe('CommitGraph auto-load history (issue #61)', () => {
function graphDataWithMore(): CommitGraphData {
const data = makeGraphData([
makeCommit('h1', 'first'),
makeCommit('h2', 'second', ['h1']),
]);
data.hasMore = true;
data.currentLimit = 100;
return data;
}

// happy-dom reports 0 for layout metrics, so pin a realistic
// scrolled-to-the-bottom state before dispatching the scroll event.
function scrollToBottom(container: HTMLElement): HTMLElement {
const graph = container.querySelector<HTMLElement>('.commit-graph');
expect(graph).toBeTruthy();
Object.defineProperty(graph, 'clientHeight', { value: 300, configurable: true });
Object.defineProperty(graph, 'scrollHeight', { value: 3000, configurable: true });
Object.defineProperty(graph, 'scrollTop', { value: 2800, configurable: true });
return graph!;
}

function getLogMessages() {
return globalThis.__postedMessages
.map(m => m.data as { type?: string; payload?: { limit?: number } })
.filter(m => m.type === 'getLog');
}

it('posts the next getLog chunk on a scroll near the bottom when enabled', async () => {
commitStore.setData(graphDataWithMore());
uiStore.autoLoadHistory = true;
const { container } = render(CommitGraph, {});
await tick();

await fireEvent.scroll(scrollToBottom(container));
await tick();

expect(getLogMessages()).toHaveLength(1);
// Same payload as the "Load more" button: currentLimit + loadMoreCount.
expect(getLogMessages()[0].payload?.limit).toBe(150);
expect(commitStore.loadingMore).toBe(true);
});

it('does nothing on scroll when the setting is disabled', async () => {
commitStore.setData(graphDataWithMore());
uiStore.autoLoadHistory = false;
const { container } = render(CommitGraph, {});
await tick();

await fireEvent.scroll(scrollToBottom(container));
await tick();

expect(getLogMessages()).toHaveLength(0);
expect(commitStore.loadingMore).toBe(false);
});

it('does not auto-load while a load-more request is already in flight', async () => {
commitStore.setData(graphDataWithMore());
uiStore.autoLoadHistory = true;
const { container } = render(CommitGraph, {});
await tick();
const graph = scrollToBottom(container);

await fireEvent.scroll(graph);
await tick();
expect(getLogMessages()).toHaveLength(1);

await fireEvent.scroll(graph);
await tick();

expect(getLogMessages()).toHaveLength(1);
});

it('does not auto-load when there is no more history', async () => {
const data = graphDataWithMore();
data.hasMore = false;
commitStore.setData(data);
uiStore.autoLoadHistory = true;
const { container } = render(CommitGraph, {});
await tick();

await fireEvent.scroll(scrollToBottom(container));
await tick();

expect(getLogMessages()).toHaveLength(0);
expect(commitStore.loadingMore).toBe(false);
});

it('does not auto-load while a search is active', async () => {
commitStore.setData(graphDataWithMore());
uiStore.autoLoadHistory = true;
const { container } = render(CommitGraph, { searchMatchedHashes: new Set(['h1']) });
await tick();

await fireEvent.scroll(scrollToBottom(container));
await tick();

expect(getLogMessages()).toHaveLength(0);
expect(commitStore.loadingMore).toBe(false);
});
});
3 changes: 3 additions & 0 deletions webview-ui/src/lib/stores/ui.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class UiStore {
operating = $state<string | null>(null);
badgeBarWidth = $state(4);
loadMoreCount = $state(50);
// Issue #61: when true, scrolling near the bottom of the graph fetches the
// next chunk automatically. Off by default so "Load more" stays manual.
autoLoadHistory = $state(false);
interactiveRebaseMode = $state<InteractiveRebaseMode>('ui');
// True while a file is selected in the commit-details panel. Owned (synced)
// by CommitDetails; read by the global Esc handler so the first Esc deselects
Expand Down
35 changes: 35 additions & 0 deletions webview-ui/src/lib/utils/__tests__/auto-load.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { AUTO_LOAD_THRESHOLD_PX, isNearBottom } from '../auto-load';

describe('isNearBottom', () => {
it('is false when the viewport is far from the bottom', () => {
expect(isNearBottom(0, 300, 3000)).toBe(false);
});

it('is true at the exact bottom', () => {
expect(isNearBottom(2700, 300, 3000)).toBe(true);
});

it('is true within the default threshold', () => {
expect(isNearBottom(2700 - AUTO_LOAD_THRESHOLD_PX, 300, 3000)).toBe(true);
});

it('is false just beyond the default threshold', () => {
expect(isNearBottom(2700 - AUTO_LOAD_THRESHOLD_PX - 1, 300, 3000)).toBe(false);
});

it('honours a caller-supplied threshold', () => {
expect(isNearBottom(2700 - 100, 300, 3000, 50)).toBe(false);
expect(isNearBottom(2700 - 50, 300, 3000, 50)).toBe(true);
});

it('treats content that fits the viewport as being at the bottom', () => {
expect(isNearBottom(0, 800, 500)).toBe(true);
});

it('is false for non-finite measurements', () => {
expect(isNearBottom(NaN, 300, 3000)).toBe(false);
expect(isNearBottom(0, Infinity, 3000)).toBe(false);
expect(isNearBottom(0, 300, NaN)).toBe(false);
});
});
Loading