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
36 changes: 35 additions & 1 deletion rtl-spec/components/commands-publish-button.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
}
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down
171 changes: 171 additions & 0 deletions rtl-spec/components/history.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
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<React.ComponentProps<typeof GistHistoryDialog>> = {}) {
return render(
<GistHistoryDialog
appState={state}
isOpen={true}
onClose={mockOnClose}
onRevisionSelect={mockOnRevisionSelect}
activeRevision={state.activeGistRevision}
{...props}
/>,
);
}

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(
<GistHistoryDialog
appState={state}
isOpen={true}
onClose={mockOnClose}
onRevisionSelect={mockOnRevisionSelect}
activeRevision="sha3"
/>,
);

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(
<GistHistoryDialog
appState={state}
isOpen={true}
onClose={mockOnClose}
onRevisionSelect={mockOnRevisionSelect}
activeRevision={state.activeGistRevision}
/>,
);

// 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();
});
});
7 changes: 6 additions & 1 deletion src/renderer/components/commands-action-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 });

Expand Down
37 changes: 32 additions & 5 deletions src/renderer/components/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,14 @@
}

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) {
Expand All @@ -57,16 +62,22 @@
}

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);
}
}

public componentWillUnmount() {
this.disposeReaction?.();
}

private async loadRevisions() {
private async loadRevisions(showLoading = true) {
const { appState, isOpen } = this.props;
const { remoteLoader } = window.app;

Expand All @@ -77,10 +88,26 @@
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);
Expand Down Expand Up @@ -118,7 +145,7 @@
);
}

private renderRevisionItem = (revision: GistRevision, index: number) => {

Check warning on line 148 in src/renderer/components/history.tsx

View workflow job for this annotation

GitHub Actions / test / Test (ubuntu-24.04-arm, armv7l)

'index' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 148 in src/renderer/components/history.tsx

View workflow job for this annotation

GitHub Actions / test / Test (macos-15-intel, x64)

'index' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 148 in src/renderer/components/history.tsx

View workflow job for this annotation

GitHub Actions / test / Test (ubuntu-latest, x64)

'index' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 148 in src/renderer/components/history.tsx

View workflow job for this annotation

GitHub Actions / test / Test (macos-latest, arm64)

'index' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 148 in src/renderer/components/history.tsx

View workflow job for this annotation

GitHub Actions / test / Test (windows-latest, x64)

'index' is defined but never used. Allowed unused args must match /^_/u
const date = new Date(revision.date).toLocaleString();
const shortSha = revision.sha.substring(0, 7);
const isActive = this.props.activeRevision === revision.sha;
Expand Down Expand Up @@ -183,7 +210,7 @@

return (
<div className="revision-list">
<ul>{revisions.reverse().map(this.renderRevisionItem)}</ul>
<ul>{[...revisions].reverse().map(this.renderRevisionItem)}</ul>
</div>
);
}
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/remote-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading