-
Notifications
You must be signed in to change notification settings - Fork 0
Add GitHub Gist sync for cross-browser/machine data persistence #374
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
Co-authored-by: rez1dent3 <[email protected]>
Co-authored-by: rez1dent3 <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds GitHub Gist sync functionality to enable cross-browser and cross-machine data persistence for UUID UI history and favorites. Users can authenticate with a GitHub Personal Access Token (PAT), sync their local data to a private gist, and restore it on other devices or after browser resets.
Key Changes:
- New Gist API integration service with create/update/read operations and token management
- Modal UI component for authentication and sync/restore actions
- Integration with existing app state management for data restoration
Reviewed changes
Copilot reviewed 7 out of 10 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/gist-sync.js |
Core service implementing GitHub Gist API operations, data serialization (capped at 100 items), and localStorage-based token management |
src/gist-sync-modal.js |
Modal component providing UI for token authentication, sync/restore operations, and connection status with visual feedback |
src/nav.js |
Adds cloud sync button to navigation bar with green indicator when authenticated, integrates modal component |
src/app.js |
Implements restoreFromGist handler to deserialize and restore items/favorites from gist data |
tests/gist-sync.js |
Unit tests for data serialization logic including item limiting and empty data handling |
.gitignore |
Updated to exclude build artifacts in public/assets/ |
public/index.html |
Updated bundle references (build artifacts that should be gitignored) |
public/assets/index-lWTPcmWB.css |
Build artifact marked for deletion |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| }, [isOpen]); |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] In the useEffect hook, the isOpen state is checked but the effect doesn't reset the state when the modal is closed. This means that if a user opens the modal, enters a token but doesn't connect, then closes and reopens the modal, the unsaved token will still be visible in the input field. Consider resetting the token state when isOpen becomes false and the user is not connected:
useEffect(() => {
if (isOpen) {
const savedToken = getGistToken();
if (savedToken) {
setToken(savedToken);
setIsConnected(true);
}
const savedLastSync = getLastSyncTimestamp();
if (savedLastSync) {
setLastSync(savedLastSync);
}
} else if (!isConnected) {
// Reset unsaved token when modal closes
setToken('');
}
}, [isOpen, isConnected]);| } | |
| }, [isOpen]); | |
| } else if (!isConnected) { | |
| // Reset unsaved token when modal closes | |
| setToken(''); | |
| } | |
| }, [isOpen, isConnected]); |
| <script type="module" crossorigin src="./assets/bundle-DScjgMdC.js"></script> | ||
| <link rel="stylesheet" crossorigin href="./assets/index-B5HI90Nx.css"> |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The generated bundle files in public/assets/ should not be committed to version control as they are build artifacts. The .gitignore has been correctly updated to exclude public/assets/, but these files (index-lWTPcmWB.css and the HTML references to bundle files) are still being tracked in this PR. These should be removed from the commit.
| <script type="module" crossorigin src="./assets/bundle-DScjgMdC.js"></script> | |
| <link rel="stylesheet" crossorigin href="./assets/index-B5HI90Nx.css"> |
| } | ||
| } | ||
|
|
||
| this.setState({ items, favorites }, () => { |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The restoreFromGist method doesn't save the restored items to localStorage. This creates an inconsistency where restored data won't persist in localStorage until the next item is added or modified via componentDidUpdate. Users could lose their restored data if they close the browser before making any changes.
Consider calling localStorage.setItem('uuidItems', JSON.stringify(...)) after updating the state, similar to the pattern used in componentDidUpdate.
| this.setState({ items, favorites }, () => { | |
| this.setState({ items, favorites }, () => { | |
| // Save restored items to localStorage | |
| try { | |
| localStorage.setItem('uuidItems', JSON.stringify( | |
| items.map(item => ({ | |
| input: item.input || '', | |
| output: item.output || '', | |
| info: item.info || '' | |
| })) | |
| )); | |
| } catch (e) { | |
| console.error('Error saving restored items to localStorage:', e); | |
| } |
| import assert from 'node:assert'; | ||
| import test from 'node:test'; | ||
| import { GistSync } from '../src/gist-sync.js'; | ||
|
|
||
| test('GistSync.serializeData - serializes items and favorites correctly', () => { | ||
| const gistSync = new GistSync('test-token'); | ||
|
|
||
| const items = [ | ||
| { input: '0;1', output: 'uuid1', info: 'comment1' }, | ||
| { input: 'uuid2', output: '0;2', info: '' } | ||
| ]; | ||
|
|
||
| const favorites = { | ||
| 'myList': [ | ||
| { input: 'uuid3', output: '0;3', info: 'fav comment' } | ||
| ] | ||
| }; | ||
|
|
||
| const result = gistSync.serializeData(items, favorites); | ||
|
|
||
| assert.strictEqual(result.version, 1); | ||
| assert.ok(result.timestamp); | ||
| assert.strictEqual(result.items.length, 2); | ||
| assert.strictEqual(result.items[0].input, '0;1'); | ||
| assert.strictEqual(result.items[0].output, 'uuid1'); | ||
| assert.strictEqual(result.items[0].info, 'comment1'); | ||
| assert.strictEqual(result.items[1].input, 'uuid2'); | ||
| assert.strictEqual(result.items[1].output, '0;2'); | ||
| assert.strictEqual(result.items[1].info, ''); | ||
| assert.ok(result.favorites.myList); | ||
| assert.strictEqual(result.favorites.myList.length, 1); | ||
| assert.strictEqual(result.favorites.myList[0].input, 'uuid3'); | ||
| }); | ||
|
|
||
| test('GistSync.serializeData - handles empty data', () => { | ||
| const gistSync = new GistSync('test-token'); | ||
|
|
||
| const result = gistSync.serializeData([], {}); | ||
|
|
||
| assert.strictEqual(result.version, 1); | ||
| assert.ok(result.timestamp); | ||
| assert.strictEqual(result.items.length, 0); | ||
| assert.deepStrictEqual(result.favorites, {}); | ||
| }); | ||
|
|
||
| test('GistSync.serializeData - limits items to 100', () => { | ||
| const gistSync = new GistSync('test-token'); | ||
|
|
||
| const items = Array(150).fill(null).map((_, i) => ({ | ||
| input: `input${i}`, | ||
| output: `output${i}`, | ||
| info: '' | ||
| })); | ||
|
|
||
| const result = gistSync.serializeData(items, {}); | ||
|
|
||
| assert.strictEqual(result.items.length, 100); | ||
| }); | ||
|
|
||
| test('GistSync.getHeaders - returns correct headers', () => { | ||
| const gistSync = new GistSync('test-token-123'); | ||
|
|
||
| const headers = gistSync.getHeaders(); | ||
|
|
||
| assert.strictEqual(headers['Authorization'], 'Bearer test-token-123'); | ||
| assert.strictEqual(headers['Accept'], 'application/vnd.github+json'); | ||
| assert.strictEqual(headers['Content-Type'], 'application/json'); | ||
| assert.strictEqual(headers['X-GitHub-Api-Version'], '2022-11-28'); | ||
| }); |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test suite only covers the synchronous serializeData and getHeaders methods, but doesn't test the core async functionality like syncToGist, restoreFromGist, findExistingGist, createGist, updateGist, and validateToken. Since the repository has comprehensive test coverage for other modules (base64, bytes, high-low, type-detector, ulid), the async API methods should also have test coverage using mocked fetch calls.
| const handleDisconnect = () => { | ||
| removeGistToken(); | ||
| setToken(''); | ||
| setIsConnected(false); | ||
| setLastSync(null); | ||
| toast.success('Disconnected from GitHub'); | ||
| }; |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When disconnecting from GitHub, the last sync timestamp is cleared from the UI state but not from localStorage. This means that after disconnecting and reconnecting with the same or different token, the old sync timestamp will reappear, which is misleading since it represents a sync from the previous connection.
The handleDisconnect function should also call localStorage.removeItem('gistLastSync') to ensure consistency.
| if (!response.ok) { | ||
| const error = await response.json().catch(() => ({})); | ||
| throw new Error(error.message || `Failed to fetch gists: ${response.status}`); | ||
| } |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error handling for findExistingGist uses response.json().catch(() => ({})) which suppresses JSON parsing errors. If the response body is not valid JSON, the error message will be generic. A more informative approach would be to handle JSON parsing errors separately or provide a fallback error message that doesn't rely on parsing the error response body.
For example:
if (!response.ok) {
let errorMessage = `Failed to fetch gists: ${response.status}`;
try {
const error = await response.json();
if (error.message) errorMessage = error.message;
} catch {
// Keep the default error message
}
throw new Error(errorMessage);
}| } catch { | ||
| return false; |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The validateToken method silently returns false on network or other errors, making it difficult to distinguish between an invalid token and network issues. This could confuse users who have a valid token but experience temporary network problems. Consider propagating the actual error or providing more specific error handling to differentiate between authentication failures and network errors.
| } catch { | |
| return false; | |
| } catch (error) { | |
| throw error; |
| const handleRestore = async () => { | ||
| const savedToken = getGistToken(); | ||
| if (!savedToken) { | ||
| toast.error('Not connected to GitHub'); | ||
| return; | ||
| } | ||
|
|
||
| setIsRestoring(true); | ||
| try { | ||
| const gistSync = new GistSync(savedToken); | ||
| const data = await gistSync.restoreFromGist(); | ||
|
|
||
| if (!data) { | ||
| toast.info('No data found', { | ||
| description: 'No UUID UI gist found in your account' | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| onRestore(data.items, data.favorites); | ||
| toast.success('Data restored', { | ||
| description: `Restored ${data.items.length} items from gist` | ||
| }); | ||
| } catch (error) { | ||
| toast.error('Restore failed', { | ||
| description: error.message | ||
| }); | ||
| } finally { | ||
| setIsRestoring(false); | ||
| } | ||
| }; |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The handleRestore function doesn't close the modal after successfully restoring data. Users might expect the modal to close automatically after a successful restore operation, similar to how many UI patterns work. Consider adding onClose() after the success toast on line 128, or provide a clear indication that users should manually close the modal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 7 out of 10 changed files in this pull request and generated 10 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| const result = gistSync.serializeData(items, {}); | ||
|
|
||
| assert.strictEqual(result.items.length, 100); |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test should verify that items beyond position 100 are actually excluded, not just that the result has 100 items. The first 100 items might be included by coincidence. Add an assertion to check the content:
assert.strictEqual(result.items.length, 100);
assert.strictEqual(result.items[0].input, 'input0');
assert.strictEqual(result.items[99].input, 'input99');
// Verify that item 100+ are not included
assert.ok(!result.items.find(item => item.input === 'input100'));| assert.strictEqual(result.items.length, 100); | |
| assert.strictEqual(result.items.length, 100); | |
| assert.strictEqual(result.items[0].input, 'input0'); | |
| assert.strictEqual(result.items[99].input, 'input99'); | |
| assert.ok(!result.items.find(item => item.input === 'input100')); |
| <button | ||
| onClick={onClose} | ||
| className={`p-2 rounded-lg ${isToggled ? 'hover:bg-gray-700 text-gray-400 hover:text-gray-300' : 'hover:bg-gray-100 text-gray-400 hover:text-gray-600'} transition-colors`} | ||
| aria-label="Close" | ||
| > | ||
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> | ||
| </svg> | ||
| </button> |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accessibility issue: The modal close button (X) at the top right should have keyboard support. Users should be able to close the modal with the Escape key. Add a keyboard event listener:
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);| } | ||
|
|
||
| this.setState({ items, favorites }, () => { | ||
| this.saveFavoritesToStorage(favorites); |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing error handling for saveFavoritesToStorage. If this method throws an error, the state will be updated but favorites won't be persisted to storage, leading to data inconsistency. Consider wrapping the callback in a try-catch block:
this.setState({ items, favorites }, () => {
try {
this.saveFavoritesToStorage(favorites);
} catch (e) {
console.error('Failed to save favorites to storage:', e);
// Consider showing a toast notification to the user
}
});| this.saveFavoritesToStorage(favorites); | |
| try { | |
| this.saveFavoritesToStorage(favorites); | |
| } catch (e) { | |
| console.error('Failed to save favorites to storage:', e); | |
| // Optionally, show a toast notification to the user here | |
| } |
| } catch { | ||
| throw new Error('Failed to parse gist content'); |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The empty catch block silently swallows parsing errors. If the gist content is malformed JSON, users won't know why the restore failed. The error message "Failed to parse gist content" should be thrown or logged with more details:
try {
const data = JSON.parse(file.content);
return {
items: data.items || [],
favorites: data.favorites || {},
timestamp: data.timestamp || null
};
} catch (e) {
console.error('Failed to parse gist content:', e);
throw new Error(`Failed to parse gist content: ${e.message}`);
}| } catch { | |
| throw new Error('Failed to parse gist content'); | |
| } catch (e) { | |
| console.error('Failed to parse gist content:', e); | |
| throw new Error(`Failed to parse gist content: ${e.message}`); |
| export function saveGistToken(token) { | ||
| try { | ||
| localStorage.setItem('gistToken', token); | ||
| } catch (e) { | ||
| console.error('Failed to save gist token:', e); | ||
| } | ||
| } |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security concern: The GitHub Personal Access Token is stored in localStorage in plain text. While this is standard practice for client-side apps, the PR description should document this security consideration. Users should be aware that tokens stored in localStorage:
- Can be accessed by any JavaScript running on the same origin
- Are vulnerable to XSS attacks
- Should use fine-grained tokens with minimal permissions (only 'gist' scope)
Consider adding a warning message in the modal UI about token security and recommending users create fine-grained tokens limited to gist access only.
| const handleSync = async () => { | ||
| const savedToken = getGistToken(); | ||
| if (!savedToken) { | ||
| toast.error('Not connected to GitHub'); | ||
| return; | ||
| } | ||
|
|
||
| setIsSyncing(true); | ||
| try { | ||
| const gistSync = new GistSync(savedToken); | ||
| await gistSync.syncToGist(items, favorites); | ||
| const timestamp = new Date().toISOString(); | ||
| saveLastSyncTimestamp(timestamp); | ||
| setLastSync(timestamp); | ||
| toast.success('Synced to GitHub Gist', { | ||
| description: 'Your data has been saved successfully' | ||
| }); | ||
| } catch (error) { | ||
| toast.error('Sync failed', { | ||
| description: error.message | ||
| }); | ||
| } finally { | ||
| setIsSyncing(false); | ||
| } |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Race condition potential: If the user rapidly clicks "Sync to Gist" multiple times, multiple API calls could be in flight simultaneously, potentially creating multiple gists or causing conflicts. While the isSyncing state prevents the button from being clicked again, a more robust solution would be to cancel in-flight requests or queue them. Consider using AbortController for cancellation or a request queue.
| <button | ||
| type="button" | ||
| onClick={() => setShowToken(!showToken)} | ||
| className={`absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded ${isToggled ? 'text-gray-400 hover:text-gray-300' : 'text-gray-400 hover:text-gray-600'}`} | ||
| aria-label={showToken ? 'Hide token' : 'Show token'} | ||
| > | ||
| {showToken ? ( | ||
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L6.05 6.05m3.828 3.828L6.05 6.05m7.875 7.875l3.828 3.828M6.05 6.05L3 3m3.05 3.05l4.243 4.243" /> | ||
| </svg> | ||
| ) : ( | ||
| <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> | ||
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> | ||
| </svg> | ||
| )} | ||
| </button> |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accessibility issue: The password visibility toggle button only has an aria-label that changes between "Hide token" and "Show token", but screen reader users won't be notified when the visibility state changes. Consider adding aria-pressed or aria-expanded attributes to communicate the current state:
<button
type="button"
onClick={() => setShowToken(!showToken)}
className={...}
aria-label={showToken ? 'Hide token' : 'Show token'}
aria-pressed={showToken}
>| isToggled | ||
| ? 'bg-gray-800/50 hover:bg-gray-700/50 text-gray-200' | ||
| : 'bg-white hover:bg-gray-50 text-gray-700' | ||
| } ${this.state.isGistConnected ? 'text-green-500' : ''}`} |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The conditional color application ${this.state.isGistConnected ? 'text-green-500' : ''} will override the dark/light mode colors defined earlier in the className. When connected, the button will always be green regardless of the theme. Consider using conditional classes that respect the theme, such as:
${this.state.isGistConnected ? (isToggled ? 'text-green-400' : 'text-green-500') : ''}| } ${this.state.isGistConnected ? 'text-green-500' : ''}`} | |
| }${this.state.isGistConnected ? (isToggled ? ' text-green-400' : ' text-green-500') : ''}`} |
| return date.toLocaleDateString('en-US', { | ||
| month: 'short', | ||
| day: 'numeric', | ||
| year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined | ||
| }); |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The formatLastSync function has an edge case bug. When the date difference is exactly 7 days or more, it falls through to toLocaleDateString, but the year condition uses !== which could cause unexpected behavior during year transitions. For example, if now is January 1, 2024 and date is December 25, 2023, the year will be shown even though it's recent. Consider using a days threshold instead:
if (diffDays < 7) return `${diffDays}d ago`;
if (diffDays < 365) {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });| return date.toLocaleDateString('en-US', { | |
| month: 'short', | |
| day: 'numeric', | |
| year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined | |
| }); | |
| if (diffDays < 365) { | |
| return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); | |
| } | |
| return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); |
| if (!response.ok) { | ||
| const error = await response.json().catch(() => ({})); | ||
| throw new Error(error.message || `Failed to fetch gists: ${response.status}`); | ||
| } | ||
|
|
||
| const gists = await response.json(); | ||
| return gists.find(gist => | ||
| gist.description === GIST_DESCRIPTION && | ||
| gist.files && | ||
| gist.files[GIST_FILENAME] | ||
| ) || null; | ||
| } | ||
|
|
||
| /** | ||
| * Create a new gist with the app data | ||
| * @param {Array} items - History items | ||
| * @param {Object} favorites - Favorites object | ||
| * @returns {Promise<Object>} - Created gist object | ||
| */ | ||
| async createGist(items, favorites) { | ||
| const data = this.serializeData(items, favorites); | ||
|
|
||
| const response = await fetch(GIST_API_URL, { | ||
| method: 'POST', | ||
| headers: this.getHeaders(), | ||
| body: JSON.stringify({ | ||
| description: GIST_DESCRIPTION, | ||
| public: false, | ||
| files: { | ||
| [GIST_FILENAME]: { | ||
| content: JSON.stringify(data, null, 2) | ||
| } | ||
| } | ||
| }) | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const error = await response.json().catch(() => ({})); | ||
| throw new Error(error.message || `Failed to create gist: ${response.status}`); | ||
| } | ||
|
|
||
| return response.json(); | ||
| } | ||
|
|
||
| /** | ||
| * Update an existing gist with the app data | ||
| * @param {string} gistId - The gist ID | ||
| * @param {Array} items - History items | ||
| * @param {Object} favorites - Favorites object | ||
| * @returns {Promise<Object>} - Updated gist object | ||
| */ | ||
| async updateGist(gistId, items, favorites) { | ||
| const data = this.serializeData(items, favorites); | ||
|
|
||
| const response = await fetch(`${GIST_API_URL}/${gistId}`, { | ||
| method: 'PATCH', | ||
| headers: this.getHeaders(), | ||
| body: JSON.stringify({ | ||
| description: GIST_DESCRIPTION, | ||
| files: { | ||
| [GIST_FILENAME]: { | ||
| content: JSON.stringify(data, null, 2) | ||
| } | ||
| } | ||
| }) | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const error = await response.json().catch(() => ({})); | ||
| throw new Error(error.message || `Failed to update gist: ${response.status}`); | ||
| } | ||
|
|
||
| return response.json(); | ||
| } | ||
|
|
||
| /** | ||
| * Sync data to GitHub Gist (creates or updates) | ||
| * @param {Array} items - History items | ||
| * @param {Object} favorites - Favorites object | ||
| * @returns {Promise<Object>} - Gist object | ||
| */ | ||
| async syncToGist(items, favorites) { | ||
| const existingGist = await this.findExistingGist(); | ||
|
|
||
| if (existingGist) { | ||
| return this.updateGist(existingGist.id, items, favorites); | ||
| } | ||
|
|
||
| return this.createGist(items, favorites); | ||
| } | ||
|
|
||
| /** | ||
| * Restore data from GitHub Gist | ||
| * @returns {Promise<Object|null>} - Restored data or null if not found | ||
| */ | ||
| async restoreFromGist() { | ||
| const existingGist = await this.findExistingGist(); | ||
|
|
||
| if (!existingGist) { | ||
| return null; | ||
| } | ||
|
|
||
| // Fetch the full gist to get the content | ||
| const response = await fetch(`${GIST_API_URL}/${existingGist.id}`, { | ||
| method: 'GET', | ||
| headers: this.getHeaders() | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const error = await response.json().catch(() => ({})); | ||
| throw new Error(error.message || `Failed to fetch gist: ${response.status}`); |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate error handling code. The pattern of checking !response.ok, attempting to parse JSON with a catch, and throwing an error is repeated in lines 67-68, 102-104, 133-135, and 174-176. Consider extracting this into a helper method:
async handleApiError(response, operation) {
const error = await response.json().catch((e) => {
console.error('Failed to parse error response:', e);
return {};
});
throw new Error(error.message || `Failed to ${operation}: ${response.status}`);
}Then use: if (!response.ok) await this.handleApiError(response, 'fetch gists');
Adds the ability to sync UUID UI history and favorites to GitHub Gist, enabling data restoration after browser resets and cross-device access without a custom backend.
Changes
src/gist-sync.js- Gist API service handling create/update/read operations, token management, and data serializationsrc/gist-sync-modal.js- Modal UI for token entry, sync/restore actions, and connection statussrc/nav.js- Sync button in nav bar with connected state indicator (green when authenticated)src/app.js- AddedrestoreFromGisthandler to replace local state with gist datatests/gist-sync.js- Unit tests for serialization logicUsage
gistscope (link provided in modal)Data is stored in a private gist as
uuid-ui-data.jsonwith items capped at 100 entries.Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.