Skip to content

Commit 9742655

Browse files
committed
fix: gist history updating on gist action
1 parent 45c58bc commit 9742655

File tree

6 files changed

+351
-9
lines changed

6 files changed

+351
-9
lines changed

rtl-spec/components/commands-publish-button.spec.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,22 @@ vi.mock('../../src/renderer/utils/octokit');
1818

1919
class OctokitMock {
2020
private static nextId = 1;
21+
private static nextVersion = 1;
2122

2223
public authenticate = vi.fn();
2324
public gists = {
2425
create: vi.fn().mockImplementation(() => ({
2526
data: {
2627
id: OctokitMock.nextId++,
28+
history: [{ version: `created-sha-${OctokitMock.nextVersion++}` }],
2729
},
2830
})),
2931
delete: vi.fn(),
30-
update: vi.fn(),
32+
update: vi.fn().mockImplementation(() => ({
33+
data: {
34+
history: [{ version: `updated-sha-${OctokitMock.nextVersion++}` }],
35+
},
36+
})),
3137
get: vi.fn(),
3238
};
3339
}
@@ -263,6 +269,21 @@ describe('Action button component', () => {
263269
public: true,
264270
});
265271
});
272+
273+
it('sets activeGistRevision to the new revision SHA after publishing', async () => {
274+
const revisionSha = 'new-publish-revision-sha';
275+
mocktokit.gists.create.mockImplementationOnce(() => ({
276+
data: {
277+
id: 'new-gist-id',
278+
history: [{ version: revisionSha }],
279+
},
280+
}));
281+
282+
state.showInputDialog = vi.fn().mockResolvedValueOnce(description);
283+
await instance.performGistAction();
284+
285+
expect(state.activeGistRevision).toBe(revisionSha);
286+
});
266287
});
267288

268289
describe('update mode', () => {
@@ -291,6 +312,19 @@ describe('Action button component', () => {
291312
});
292313
});
293314

315+
it('sets activeGistRevision to the new revision SHA after updating', async () => {
316+
const revisionSha = 'new-update-revision-sha';
317+
mocktokit.gists.update.mockImplementationOnce(() => ({
318+
data: {
319+
history: [{ version: revisionSha }],
320+
},
321+
}));
322+
323+
await instance.performGistAction();
324+
325+
expect(state.activeGistRevision).toBe(revisionSha);
326+
});
327+
294328
it('notifies the user if updating fails', async () => {
295329
mocktokit.gists.update.mockImplementation(() => {
296330
throw new Error(errorMessage);
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import * as React from 'react';
2+
3+
import { Octokit } from '@octokit/rest';
4+
import { render, screen, waitFor } from '@testing-library/react';
5+
import { beforeEach, describe, expect, it, vi } from 'vitest';
6+
7+
import { GistRevision } from '../../src/interfaces';
8+
import { App } from '../../src/renderer/app';
9+
import { GistHistoryDialog } from '../../src/renderer/components/history';
10+
import { AppState } from '../../src/renderer/state';
11+
import { getOctokit } from '../../src/renderer/utils/octokit';
12+
13+
vi.mock('../../src/renderer/utils/octokit');
14+
15+
describe('GistHistoryDialog component', () => {
16+
let app: App;
17+
let state: AppState;
18+
let mockGetGistRevisions: ReturnType<typeof vi.fn>;
19+
const mockOnClose = vi.fn();
20+
const mockOnRevisionSelect = vi.fn();
21+
22+
const mockRevisions: GistRevision[] = [
23+
{
24+
sha: 'sha1',
25+
date: '2026-02-01T10:00:00Z',
26+
title: 'Created',
27+
changes: { additions: 10, deletions: 0, total: 10 },
28+
},
29+
{
30+
sha: 'sha2',
31+
date: '2026-02-05T12:00:00Z',
32+
title: 'Revision 1',
33+
changes: { additions: 5, deletions: 2, total: 7 },
34+
},
35+
];
36+
37+
beforeEach(() => {
38+
({ app } = window);
39+
({ state } = app);
40+
41+
mockGetGistRevisions = vi.fn().mockResolvedValue(mockRevisions);
42+
(window.app as any).remoteLoader = {
43+
getGistRevisions: mockGetGistRevisions,
44+
};
45+
46+
state.gistId = 'test-gist-id';
47+
state.activeGistRevision = 'sha2';
48+
49+
vi.mocked(getOctokit).mockResolvedValue({} as unknown as Octokit);
50+
});
51+
52+
function renderDialog(props: Partial<React.ComponentProps<typeof GistHistoryDialog>> = {}) {
53+
return render(
54+
<GistHistoryDialog
55+
appState={state}
56+
isOpen={true}
57+
onClose={mockOnClose}
58+
onRevisionSelect={mockOnRevisionSelect}
59+
activeRevision={state.activeGistRevision}
60+
{...props}
61+
/>,
62+
);
63+
}
64+
65+
it('renders and loads revisions when open', async () => {
66+
renderDialog();
67+
68+
await waitFor(() => {
69+
expect(mockGetGistRevisions).toHaveBeenCalledWith('test-gist-id');
70+
});
71+
72+
expect(screen.getByText('Created')).toBeInTheDocument();
73+
expect(screen.getByText('Revision 1')).toBeInTheDocument();
74+
});
75+
76+
it('does not load revisions when closed', () => {
77+
renderDialog({ isOpen: false });
78+
79+
expect(mockGetGistRevisions).not.toHaveBeenCalled();
80+
});
81+
82+
it('shows the Active tag on the active revision', async () => {
83+
renderDialog({ activeRevision: 'sha2' });
84+
85+
await waitFor(() => {
86+
expect(screen.getByText('Active')).toBeInTheDocument();
87+
});
88+
});
89+
90+
it('reloads revisions when activeRevision prop changes', async () => {
91+
const { rerender } = renderDialog({ activeRevision: 'sha1' });
92+
93+
await waitFor(() => {
94+
expect(mockGetGistRevisions).toHaveBeenCalledTimes(1);
95+
});
96+
97+
// Simulate an update operation that changes activeRevision
98+
rerender(
99+
<GistHistoryDialog
100+
appState={state}
101+
isOpen={true}
102+
onClose={mockOnClose}
103+
onRevisionSelect={mockOnRevisionSelect}
104+
activeRevision="sha3"
105+
/>,
106+
);
107+
108+
await waitFor(() => {
109+
expect(mockGetGistRevisions).toHaveBeenCalledTimes(2);
110+
});
111+
});
112+
113+
it('adds a placeholder for active revision not in the list', async () => {
114+
mockGetGistRevisions.mockResolvedValue([mockRevisions[0]]); // Only "Created"
115+
state.activeGistRevision = 'new-sha-not-in-list';
116+
117+
renderDialog({ activeRevision: 'new-sha-not-in-list' });
118+
119+
await waitFor(() => {
120+
expect(screen.getByText('Active')).toBeInTheDocument();
121+
});
122+
123+
// The placeholder revision should be shown
124+
expect(screen.getByText(/new-sha/i)).toBeInTheDocument();
125+
});
126+
127+
it('does not mutate revisions array when rendering', async () => {
128+
const revisionsCopy = [...mockRevisions];
129+
mockGetGistRevisions.mockResolvedValue(revisionsCopy);
130+
131+
const { rerender } = renderDialog();
132+
133+
await waitFor(() => {
134+
expect(screen.getByText('Created')).toBeInTheDocument();
135+
});
136+
137+
// Re-render to trigger another render cycle
138+
rerender(
139+
<GistHistoryDialog
140+
appState={state}
141+
isOpen={true}
142+
onClose={mockOnClose}
143+
onRevisionSelect={mockOnRevisionSelect}
144+
activeRevision={state.activeGistRevision}
145+
/>,
146+
);
147+
148+
// The order should still be consistent (newest first in display)
149+
const items = screen.getAllByRole('listitem');
150+
expect(items).toHaveLength(2);
151+
});
152+
153+
it('shows error state when no gist ID is available', async () => {
154+
state.gistId = undefined;
155+
156+
renderDialog();
157+
158+
await waitFor(() => {
159+
expect(screen.getByText('No Gist ID available')).toBeInTheDocument();
160+
});
161+
});
162+
163+
it('shows loading state initially', () => {
164+
// Make the promise never resolve to keep loading state
165+
mockGetGistRevisions.mockImplementation(() => new Promise(() => {}));
166+
167+
renderDialog();
168+
169+
expect(screen.getByText('Loading revision history...')).toBeInTheDocument();
170+
});
171+
});

src/renderer/components/commands-action-button.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ export const GistActionButton = observer(
133133
});
134134

135135
appState.gistId = gist.data.id;
136-
appState.activeGistRevision = undefined;
136+
appState.activeGistRevision = gist.data.history?.[0]?.version;
137137
appState.localPath = undefined;
138138

139139
if (appState.isPublishingGistAsRevision) {
@@ -215,6 +215,11 @@ export const GistActionButton = observer(
215215
files,
216216
});
217217

218+
// Update the active revision to the newly created revision
219+
if (gist.data.history?.[0]?.version) {
220+
appState.activeGistRevision = gist.data.history[0].version;
221+
}
222+
218223
await appState.editorMosaic.markAsSaved();
219224
console.log('Updating: Updating done', { gist });
220225

src/renderer/components/history.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,14 @@ export class GistHistoryDialog extends React.Component<
4646
}
4747

4848
public componentDidMount() {
49+
// Reload revisions when gistId changes while dialog is open
4950
this.disposeReaction = reaction(
5051
() => this.props.appState.gistId,
51-
() => this.loadRevisions(),
52+
() => {
53+
if (this.props.isOpen) {
54+
this.loadRevisions();
55+
}
56+
},
5257
);
5358

5459
if (this.props.isOpen) {
@@ -57,16 +62,22 @@ export class GistHistoryDialog extends React.Component<
5762
}
5863

5964
public componentDidUpdate(prevProps: HistoryProps) {
60-
if (this.props.isOpen && !prevProps.isOpen) {
65+
const dialogJustOpened = this.props.isOpen && !prevProps.isOpen;
66+
const revisionChanged =
67+
this.props.activeRevision !== prevProps.activeRevision;
68+
69+
if (dialogJustOpened) {
6170
this.loadRevisions();
71+
} else if (this.props.isOpen && revisionChanged) {
72+
this.loadRevisions(false);
6273
}
6374
}
6475

6576
public componentWillUnmount() {
6677
this.disposeReaction?.();
6778
}
6879

69-
private async loadRevisions() {
80+
private async loadRevisions(showLoading = true) {
7081
const { appState, isOpen } = this.props;
7182
const { remoteLoader } = window.app;
7283

@@ -77,10 +88,26 @@ export class GistHistoryDialog extends React.Component<
7788
return;
7889
}
7990

80-
this.setState({ isLoading: true, error: null });
91+
if (showLoading) {
92+
this.setState({ isLoading: true, error: null });
93+
}
8194

8295
try {
8396
const revisions = await remoteLoader.getGistRevisions(appState.gistId);
97+
98+
const { activeGistRevision } = appState;
99+
if (
100+
activeGistRevision &&
101+
!revisions.some((r) => r.sha === activeGistRevision)
102+
) {
103+
revisions.push({
104+
sha: activeGistRevision,
105+
date: new Date().toISOString(),
106+
title: `Revision ${revisions.length}`,
107+
changes: { additions: 0, deletions: 0, total: 0 },
108+
});
109+
}
110+
84111
this.setState({ revisions, isLoading: false });
85112
} catch (error) {
86113
console.error('Failed to load gist revisions', error);
@@ -183,7 +210,7 @@ export class GistHistoryDialog extends React.Component<
183210

184211
return (
185212
<div className="revision-list">
186-
<ul>{revisions.reverse().map(this.renderRevisionItem)}</ul>
213+
<ul>{[...revisions].reverse().map(this.renderRevisionItem)}</ul>
187214
</div>
188215
);
189216
}

src/renderer/remote-loader.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,12 @@ export class RemoteLoader {
123123
gist_id: gistId,
124124
});
125125

126-
// Filter out empty revisions (0 additions and 0 deletions)
126+
const oldestRevision = revisions[revisions.length - 1];
127127
const nonEmptyRevisions = revisions.filter(
128-
(r) => r.change_status.additions > 0 || r.change_status.deletions > 0,
128+
(r) =>
129+
r === oldestRevision ||
130+
r.change_status.additions > 0 ||
131+
r.change_status.deletions > 0,
129132
);
130133

131134
return nonEmptyRevisions.reverse().map((r, i) => {

0 commit comments

Comments
 (0)