diff --git a/rtl-spec/components/commands-publish-button.spec.tsx b/rtl-spec/components/commands-publish-button.spec.tsx index f8de7c79a2..6f297f48a6 100644 --- a/rtl-spec/components/commands-publish-button.spec.tsx +++ b/rtl-spec/components/commands-publish-button.spec.tsx @@ -18,16 +18,22 @@ vi.mock('../../src/renderer/utils/octokit'); class OctokitMock { private static nextId = 1; + private static nextVersion = 1; public authenticate = vi.fn(); public gists = { create: vi.fn().mockImplementation(() => ({ data: { id: OctokitMock.nextId++, + history: [{ version: `created-sha-${OctokitMock.nextVersion++}` }], }, })), delete: vi.fn(), - update: vi.fn(), + update: vi.fn().mockImplementation(() => ({ + data: { + history: [{ version: `updated-sha-${OctokitMock.nextVersion++}` }], + }, + })), get: vi.fn(), }; } @@ -263,6 +269,21 @@ describe('Action button component', () => { public: true, }); }); + + it('sets activeGistRevision to the new revision SHA after publishing', async () => { + const revisionSha = 'new-publish-revision-sha'; + mocktokit.gists.create.mockImplementationOnce(() => ({ + data: { + id: 'new-gist-id', + history: [{ version: revisionSha }], + }, + })); + + state.showInputDialog = vi.fn().mockResolvedValueOnce(description); + await instance.performGistAction(); + + expect(state.activeGistRevision).toBe(revisionSha); + }); }); describe('update mode', () => { @@ -291,6 +312,19 @@ describe('Action button component', () => { }); }); + it('sets activeGistRevision to the new revision SHA after updating', async () => { + const revisionSha = 'new-update-revision-sha'; + mocktokit.gists.update.mockImplementationOnce(() => ({ + data: { + history: [{ version: revisionSha }], + }, + })); + + await instance.performGistAction(); + + expect(state.activeGistRevision).toBe(revisionSha); + }); + it('notifies the user if updating fails', async () => { mocktokit.gists.update.mockImplementation(() => { throw new Error(errorMessage); diff --git a/rtl-spec/components/history.spec.tsx b/rtl-spec/components/history.spec.tsx new file mode 100644 index 0000000000..c06c258432 --- /dev/null +++ b/rtl-spec/components/history.spec.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; + +import { Octokit } from '@octokit/rest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GistRevision } from '../../src/interfaces'; +import { App } from '../../src/renderer/app'; +import { GistHistoryDialog } from '../../src/renderer/components/history'; +import { AppState } from '../../src/renderer/state'; +import { getOctokit } from '../../src/renderer/utils/octokit'; + +vi.mock('../../src/renderer/utils/octokit'); + +describe('GistHistoryDialog component', () => { + let app: App; + let state: AppState; + let mockGetGistRevisions: ReturnType; + const mockOnClose = vi.fn(); + const mockOnRevisionSelect = vi.fn(); + + const mockRevisions: GistRevision[] = [ + { + sha: 'sha1', + date: '2026-02-01T10:00:00Z', + title: 'Created', + changes: { additions: 10, deletions: 0, total: 10 }, + }, + { + sha: 'sha2', + date: '2026-02-05T12:00:00Z', + title: 'Revision 1', + changes: { additions: 5, deletions: 2, total: 7 }, + }, + ]; + + beforeEach(() => { + ({ app } = window); + ({ state } = app); + + mockGetGistRevisions = vi.fn().mockResolvedValue(mockRevisions); + (window.app as any).remoteLoader = { + getGistRevisions: mockGetGistRevisions, + }; + + state.gistId = 'test-gist-id'; + state.activeGistRevision = 'sha2'; + + vi.mocked(getOctokit).mockResolvedValue({} as unknown as Octokit); + }); + + function renderDialog(props: Partial> = {}) { + return render( + , + ); + } + + it('renders and loads revisions when open', async () => { + renderDialog(); + + await waitFor(() => { + expect(mockGetGistRevisions).toHaveBeenCalledWith('test-gist-id'); + }); + + expect(screen.getByText('Created')).toBeInTheDocument(); + expect(screen.getByText('Revision 1')).toBeInTheDocument(); + }); + + it('does not load revisions when closed', () => { + renderDialog({ isOpen: false }); + + expect(mockGetGistRevisions).not.toHaveBeenCalled(); + }); + + it('shows the Active tag on the active revision', async () => { + renderDialog({ activeRevision: 'sha2' }); + + await waitFor(() => { + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + }); + + it('reloads revisions when activeRevision prop changes', async () => { + const { rerender } = renderDialog({ activeRevision: 'sha1' }); + + await waitFor(() => { + expect(mockGetGistRevisions).toHaveBeenCalledTimes(1); + }); + + // Simulate an update operation that changes activeRevision + rerender( + , + ); + + await waitFor(() => { + expect(mockGetGistRevisions).toHaveBeenCalledTimes(2); + }); + }); + + it('adds a placeholder for active revision not in the list', async () => { + mockGetGistRevisions.mockResolvedValue([mockRevisions[0]]); // Only "Created" + state.activeGistRevision = 'new-sha-not-in-list'; + + renderDialog({ activeRevision: 'new-sha-not-in-list' }); + + await waitFor(() => { + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + // The placeholder revision should be shown + expect(screen.getByText(/new-sha/i)).toBeInTheDocument(); + }); + + it('does not mutate revisions array when rendering', async () => { + const revisionsCopy = [...mockRevisions]; + mockGetGistRevisions.mockResolvedValue(revisionsCopy); + + const { rerender } = renderDialog(); + + await waitFor(() => { + expect(screen.getByText('Created')).toBeInTheDocument(); + }); + + // Re-render to trigger another render cycle + rerender( + , + ); + + // The order should still be consistent (newest first in display) + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(2); + }); + + it('shows error state when no gist ID is available', async () => { + state.gistId = undefined; + + renderDialog(); + + await waitFor(() => { + expect(screen.getByText('No Gist ID available')).toBeInTheDocument(); + }); + }); + + it('shows loading state initially', () => { + // Make the promise never resolve to keep loading state + mockGetGistRevisions.mockImplementation(() => new Promise(() => {})); + + renderDialog(); + + expect(screen.getByText('Loading revision history...')).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx index 9c3e976dde..5fa7a11cec 100644 --- a/src/renderer/components/commands-action-button.tsx +++ b/src/renderer/components/commands-action-button.tsx @@ -133,7 +133,7 @@ export const GistActionButton = observer( }); appState.gistId = gist.data.id; - appState.activeGistRevision = undefined; + appState.activeGistRevision = gist.data.history?.[0]?.version; appState.localPath = undefined; if (appState.isPublishingGistAsRevision) { @@ -215,6 +215,11 @@ export const GistActionButton = observer( files, }); + // Update the active revision to the newly created revision + if (gist.data.history?.[0]?.version) { + appState.activeGistRevision = gist.data.history[0].version; + } + await appState.editorMosaic.markAsSaved(); console.log('Updating: Updating done', { gist }); diff --git a/src/renderer/components/history.tsx b/src/renderer/components/history.tsx index c8a576c885..5a0817e358 100644 --- a/src/renderer/components/history.tsx +++ b/src/renderer/components/history.tsx @@ -46,9 +46,14 @@ export class GistHistoryDialog extends React.Component< } public componentDidMount() { + // Reload revisions when gistId changes while dialog is open this.disposeReaction = reaction( () => this.props.appState.gistId, - () => this.loadRevisions(), + () => { + if (this.props.isOpen) { + this.loadRevisions(); + } + }, ); if (this.props.isOpen) { @@ -57,8 +62,14 @@ export class GistHistoryDialog extends React.Component< } public componentDidUpdate(prevProps: HistoryProps) { - if (this.props.isOpen && !prevProps.isOpen) { + const dialogJustOpened = this.props.isOpen && !prevProps.isOpen; + const revisionChanged = + this.props.activeRevision !== prevProps.activeRevision; + + if (dialogJustOpened) { this.loadRevisions(); + } else if (this.props.isOpen && revisionChanged) { + this.loadRevisions(false); } } @@ -66,7 +77,7 @@ export class GistHistoryDialog extends React.Component< this.disposeReaction?.(); } - private async loadRevisions() { + private async loadRevisions(showLoading = true) { const { appState, isOpen } = this.props; const { remoteLoader } = window.app; @@ -77,10 +88,26 @@ export class GistHistoryDialog extends React.Component< return; } - this.setState({ isLoading: true, error: null }); + if (showLoading) { + this.setState({ isLoading: true, error: null }); + } try { const revisions = await remoteLoader.getGistRevisions(appState.gistId); + + const { activeGistRevision } = appState; + if ( + activeGistRevision && + !revisions.some((r) => r.sha === activeGistRevision) + ) { + revisions.push({ + sha: activeGistRevision, + date: new Date().toISOString(), + title: `Revision ${revisions.length}`, + changes: { additions: 0, deletions: 0, total: 0 }, + }); + } + this.setState({ revisions, isLoading: false }); } catch (error) { console.error('Failed to load gist revisions', error); @@ -183,7 +210,7 @@ export class GistHistoryDialog extends React.Component< return (
-
    {revisions.reverse().map(this.renderRevisionItem)}
+
    {[...revisions].reverse().map(this.renderRevisionItem)}
); } diff --git a/src/renderer/remote-loader.ts b/src/renderer/remote-loader.ts index 005004909a..1db7a2bd16 100644 --- a/src/renderer/remote-loader.ts +++ b/src/renderer/remote-loader.ts @@ -123,9 +123,12 @@ export class RemoteLoader { gist_id: gistId, }); - // Filter out empty revisions (0 additions and 0 deletions) + const oldestRevision = revisions[revisions.length - 1]; const nonEmptyRevisions = revisions.filter( - (r) => r.change_status.additions > 0 || r.change_status.deletions > 0, + (r) => + r === oldestRevision || + r.change_status.additions > 0 || + r.change_status.deletions > 0, ); return nonEmptyRevisions.reverse().map((r, i) => { diff --git a/tests/renderer/remote-loader-spec.ts b/tests/renderer/remote-loader-spec.ts index 915bbbc4f1..38858fac77 100644 --- a/tests/renderer/remote-loader-spec.ts +++ b/tests/renderer/remote-loader-spec.ts @@ -513,4 +513,106 @@ describe('RemoteLoader', () => { expect(store.showConfirmDialog).toHaveBeenCalled(); }); }); + + describe('getGistRevisions()', () => { + it('returns revisions from the API', async () => { + const mockListCommits = vi.fn().mockResolvedValue({ + data: [ + { + version: 'sha2', + committed_at: '2026-02-05T12:00:00Z', + change_status: { additions: 5, deletions: 2, total: 7 }, + }, + { + version: 'sha1', + committed_at: '2026-02-01T10:00:00Z', + change_status: { additions: 10, deletions: 0, total: 10 }, + }, + ], + }); + vi.mocked(getOctokit).mockResolvedValue({ + gists: { listCommits: mockListCommits }, + } as unknown as Octokit); + + const revisions = await instance.getGistRevisions('test-gist-id'); + + expect(mockListCommits).toHaveBeenCalledWith({ gist_id: 'test-gist-id' }); + expect(revisions).toHaveLength(2); + expect(revisions[0].sha).toBe('sha1'); + expect(revisions[0].title).toBe('Created'); + expect(revisions[1].sha).toBe('sha2'); + expect(revisions[1].title).toBe('Revision 1'); + }); + + it('always keeps the initial revision even with empty change_status', async () => { + const mockListCommits = vi.fn().mockResolvedValue({ + data: [ + { + version: 'sha2', + committed_at: '2026-02-05T12:00:00Z', + change_status: { additions: 5, deletions: 2, total: 7 }, + }, + { + version: 'sha1', + committed_at: '2026-02-01T10:00:00Z', + change_status: { additions: 0, deletions: 0, total: 0 }, + }, + ], + }); + vi.mocked(getOctokit).mockResolvedValue({ + gists: { listCommits: mockListCommits }, + } as unknown as Octokit); + + const revisions = await instance.getGistRevisions('test-gist-id'); + + // Should include both revisions - the initial one should NOT be filtered out + expect(revisions).toHaveLength(2); + expect(revisions[0].sha).toBe('sha1'); + expect(revisions[0].title).toBe('Created'); + }); + + it('filters out empty revisions except the initial one', async () => { + const mockListCommits = vi.fn().mockResolvedValue({ + data: [ + { + version: 'sha3', + committed_at: '2026-02-10T12:00:00Z', + change_status: { additions: 3, deletions: 1, total: 4 }, + }, + { + version: 'sha2', + committed_at: '2026-02-05T12:00:00Z', + change_status: { additions: 0, deletions: 0, total: 0 }, + }, + { + version: 'sha1', + committed_at: '2026-02-01T10:00:00Z', + change_status: { additions: 0, deletions: 0, total: 0 }, + }, + ], + }); + vi.mocked(getOctokit).mockResolvedValue({ + gists: { listCommits: mockListCommits }, + } as unknown as Octokit); + + const revisions = await instance.getGistRevisions('test-gist-id'); + + // Should filter out sha2 (empty, not initial) but keep sha1 (initial) and sha3 (has changes) + expect(revisions).toHaveLength(2); + expect(revisions[0].sha).toBe('sha1'); + expect(revisions[1].sha).toBe('sha3'); + }); + + it('returns empty array on error', async () => { + vi.mocked(getOctokit).mockResolvedValue({ + gists: { + listCommits: vi.fn().mockRejectedValue(new Error('API error')), + }, + } as unknown as Octokit); + + const revisions = await instance.getGistRevisions('test-gist-id'); + + expect(revisions).toEqual([]); + }); + }); });