From ab88cf721c57636c6e0fdb335d30883d3f6da60b Mon Sep 17 00:00:00 2001 From: hackjutsu Date: Sun, 30 Aug 2026 19:04:36 -0700 Subject: [PATCH] Preserve new snippet drafts when creation fails --- app/containers/userPanel/index.js | 80 ++++++++++---- app/utilities/i18n/locales/en.js | 1 + app/utilities/i18n/locales/es.js | 1 + app/utilities/i18n/locales/fr.js | 1 + app/utilities/i18n/locales/ja.js | 1 + app/utilities/i18n/locales/ko.js | 1 + app/utilities/i18n/locales/tr.js | 1 + app/utilities/i18n/locales/zh-Hans.js | 1 + app/utilities/i18n/locales/zh-Hant.js | 1 + app/utilities/newGistDraft.js | 95 +++++++++++++++++ tests/utilities/i18n.test.js | 3 + tests/utilities/newGistDraft.test.js | 146 ++++++++++++++++++++++++++ 12 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 app/utilities/newGistDraft.js create mode 100644 tests/utilities/newGistDraft.test.js diff --git a/app/containers/userPanel/index.js b/app/containers/userPanel/index.js index 6f85e7ba..907f86bb 100644 --- a/app/containers/userPanel/index.js +++ b/app/containers/userPanel/index.js @@ -28,6 +28,12 @@ import { CREATE_SINGLE_GIST, getGitHubApi, } from '../../utilities/githubApi' +import { + clearNewGistDraft, + createNewGistDraft, + createNewGistWithDraft, + loadNewGistDraft, +} from '../../utilities/newGistDraft' import './index.scss' @@ -53,6 +59,25 @@ const kIsPrivate = conf.get('snippet:newSnippetPrivate') const hideProfilePhoto = conf.get('userPanel:hideProfilePhoto') class UserPanel extends Component { + constructor (props) { + super(props) + this.newGistInitialData = loadNewGistDraft( + electronBridge.localStorage, + this.getUserLogin(props) + ) || this.createEmptyNewGistData() + } + + getUserLogin (props = this.props) { + const profile = props.userSession && props.userSession.profile + return profile && profile.login + } + + createEmptyNewGistData () { + return createNewGistDraft({ + private: kIsPrivate + }) + } + componentDidMount () { this.ipcSubscriptions = [] subscribeIpc(ipcRenderer, this.ipcSubscriptions, 'new-gist-renderer', () => { @@ -74,6 +99,9 @@ class UserPanel extends Component { const isPublic = data.private === undefined ? true : !data.private const description = data.description.trim() const processedFiles = {} + const userLogin = this.getUserLogin() + + this.newGistInitialData = createNewGistDraft(data) data.gistFiles.forEach((file) => { processedFiles[file.filename.trim()] = { @@ -81,17 +109,38 @@ class UserPanel extends Component { } }) - return getGitHubApi(CREATE_SINGLE_GIST)(this.props.accessToken, description, processedFiles, isPublic) - .catch((err) => { - notifyFailure(t('notification.gistCreationFailed')) - logger.error(JSON.stringify(err)) - }) - .then((response) => { - this.updateGistsStoreWithNewGist(response) - }) - .finally(() => { - this.closeGistEditorModal() - }) + return createNewGistWithDraft({ + storage: electronBridge.localStorage, + userLogin, + data, + createGist: () => getGitHubApi(CREATE_SINGLE_GIST)( + this.props.accessToken, + description, + processedFiles, + isPublic + ) + }).then((result) => { + if (result.status === 'failed') { + notifyFailure( + t('notification.gistCreationFailed'), + result.draftWrite && result.draftWrite.status ? t('notification.gistDraftSaved') : '' + ) + logger.error(result.error && result.error.message + ? result.error.message + : String(result.error)) + return + } + + this.updateGistsStoreWithNewGist(result.gistDetails) + this.newGistInitialData = this.createEmptyNewGistData() + + const draftClear = clearNewGistDraft(electronBridge.localStorage, userLogin) + if (!draftClear || !draftClear.status) { + logger.error('Failed to clear the saved new snippet draft') + } + + this.closeGistEditorModal() + }) } updateGistsStoreWithNewGist (gistDetails) { @@ -159,16 +208,9 @@ class UserPanel extends Component { } renderGistEditorModalBody () { - const initialData = { - description: '', - private: kIsPrivate, - gists: [ - { filename: '', content: '' } - ] - } return ( diff --git a/app/utilities/i18n/locales/en.js b/app/utilities/i18n/locales/en.js index cb5ef883..4aabd873 100644 --- a/app/utilities/i18n/locales/en.js +++ b/app/utilities/i18n/locales/en.js @@ -105,6 +105,7 @@ module.exports = { deletionFailed: 'Deletion failed', gistCreated: 'Snippet created', gistCreationFailed: 'Snippet creation failed', + gistDraftSaved: 'Your draft was saved locally. Retry when your connection returns.', gistDeleted: 'The snippet has been deleted', gistUpdateFailed: 'Snippet update failed', gistUpdated: 'Snippet updated', diff --git a/app/utilities/i18n/locales/es.js b/app/utilities/i18n/locales/es.js index 59adef47..8eb6287d 100644 --- a/app/utilities/i18n/locales/es.js +++ b/app/utilities/i18n/locales/es.js @@ -105,6 +105,7 @@ module.exports = { deletionFailed: 'No se pudo eliminar', gistCreated: 'Snippet creado', gistCreationFailed: 'No se pudo crear el snippet', + gistDraftSaved: 'Tu borrador se guardo localmente. Vuelve a intentarlo cuando se restablezca la conexion.', gistDeleted: 'El snippet se elimino', gistUpdateFailed: 'No se pudo actualizar el snippet', gistUpdated: 'Snippet actualizado', diff --git a/app/utilities/i18n/locales/fr.js b/app/utilities/i18n/locales/fr.js index 6c64bb67..a4864551 100644 --- a/app/utilities/i18n/locales/fr.js +++ b/app/utilities/i18n/locales/fr.js @@ -105,6 +105,7 @@ module.exports = { deletionFailed: 'Suppression echouee', gistCreated: 'Extrait cree', gistCreationFailed: 'Creation de l extrait echouee', + gistDraftSaved: 'Votre brouillon a ete enregistre localement. Reessayez lorsque votre connexion sera retablie.', gistDeleted: 'L extrait a ete supprime', gistUpdateFailed: 'Mise a jour de l extrait echouee', gistUpdated: 'Extrait mis a jour', diff --git a/app/utilities/i18n/locales/ja.js b/app/utilities/i18n/locales/ja.js index ef7f3720..d8b8e31b 100644 --- a/app/utilities/i18n/locales/ja.js +++ b/app/utilities/i18n/locales/ja.js @@ -105,6 +105,7 @@ module.exports = { deletionFailed: '削除に失敗しました', gistCreated: 'スニペットを作成しました', gistCreationFailed: 'スニペットの作成に失敗しました', + gistDraftSaved: '下書きをローカルに保存しました。接続が復旧したら再試行してください。', gistDeleted: 'スニペットを削除しました', gistUpdateFailed: 'スニペットの更新に失敗しました', gistUpdated: 'スニペットを更新しました', diff --git a/app/utilities/i18n/locales/ko.js b/app/utilities/i18n/locales/ko.js index d8d4da8a..743628d2 100644 --- a/app/utilities/i18n/locales/ko.js +++ b/app/utilities/i18n/locales/ko.js @@ -105,6 +105,7 @@ module.exports = { deletionFailed: '삭제 실패', gistCreated: '스니펫이 생성되었습니다', gistCreationFailed: '스니펫 생성 실패', + gistDraftSaved: '초안이 로컬에 저장되었습니다. 연결이 복구되면 다시 시도하세요.', gistDeleted: '스니펫이 삭제되었습니다', gistUpdateFailed: '스니펫 업데이트 실패', gistUpdated: '스니펫이 업데이트되었습니다', diff --git a/app/utilities/i18n/locales/tr.js b/app/utilities/i18n/locales/tr.js index 5f89716f..c509289f 100644 --- a/app/utilities/i18n/locales/tr.js +++ b/app/utilities/i18n/locales/tr.js @@ -105,6 +105,7 @@ module.exports = { deletionFailed: 'Silme başarısız', gistCreated: 'Kod parçası oluşturuldu', gistCreationFailed: 'Kod parçası oluşturulamadı', + gistDraftSaved: 'Taslağınız yerel olarak kaydedildi. Bağlantınız geri geldiğinde tekrar deneyin.', gistDeleted: 'Kod parçası silindi', gistUpdateFailed: 'Kod parçası güncellenemedi', gistUpdated: 'Kod parçası güncellendi', diff --git a/app/utilities/i18n/locales/zh-Hans.js b/app/utilities/i18n/locales/zh-Hans.js index 4dc7404b..0529ee21 100644 --- a/app/utilities/i18n/locales/zh-Hans.js +++ b/app/utilities/i18n/locales/zh-Hans.js @@ -105,6 +105,7 @@ module.exports = { deletionFailed: '删除失败', gistCreated: '代码片段已创建', gistCreationFailed: '代码片段创建失败', + gistDraftSaved: '草稿已保存在本地。网络恢复后请重试。', gistDeleted: '代码片段已删除', gistUpdateFailed: '代码片段更新失败', gistUpdated: '代码片段已更新', diff --git a/app/utilities/i18n/locales/zh-Hant.js b/app/utilities/i18n/locales/zh-Hant.js index 89ad2aa8..290ba921 100644 --- a/app/utilities/i18n/locales/zh-Hant.js +++ b/app/utilities/i18n/locales/zh-Hant.js @@ -105,6 +105,7 @@ module.exports = { deletionFailed: '刪除失敗', gistCreated: '程式碼片段已建立', gistCreationFailed: '程式碼片段建立失敗', + gistDraftSaved: '草稿已儲存在本機。網路恢復後請重試。', gistDeleted: '程式碼片段已刪除', gistUpdateFailed: '程式碼片段更新失敗', gistUpdated: '程式碼片段已更新', diff --git a/app/utilities/newGistDraft.js b/app/utilities/newGistDraft.js new file mode 100644 index 00000000..45c90dbf --- /dev/null +++ b/app/utilities/newGistDraft.js @@ -0,0 +1,95 @@ +const NEW_GIST_DRAFT_STORAGE_PREFIX = 'new-gist-draft' + +function createStorageFailure (error) { + return { + status: false, + error + } +} + +export function getNewGistDraftStorageKey (userLogin) { + const owner = typeof userLogin === 'string' && userLogin.trim() + ? encodeURIComponent(userLogin.trim()) + : 'anonymous' + + return `${NEW_GIST_DRAFT_STORAGE_PREFIX}-${owner}` +} + +export function createNewGistDraft (data = {}) { + const sourceFiles = Array.isArray(data.gistFiles) + ? data.gistFiles + : data.gists + const gists = Array.isArray(sourceFiles) + ? sourceFiles.map(file => ({ + filename: file && typeof file.filename === 'string' ? file.filename : '', + content: file && typeof file.content === 'string' ? file.content : '' + })) + : [] + + return { + description: typeof data.description === 'string' ? data.description : '', + private: Boolean(data.private), + gists: gists.length ? gists : [{ filename: '', content: '' }] + } +} + +function isNewGistDraft (draft) { + return Boolean( + draft && + typeof draft === 'object' && + typeof draft.description === 'string' && + typeof draft.private === 'boolean' && + Array.isArray(draft.gists) && + draft.gists.length && + draft.gists.every(file => + file && + typeof file.filename === 'string' && + typeof file.content === 'string' + ) + ) +} + +export function loadNewGistDraft (storage, userLogin) { + try { + const result = storage.get(getNewGistDraftStorageKey(userLogin)) + if (!result || !result.status || !isNewGistDraft(result.data)) return null + return createNewGistDraft(result.data) + } catch { + return null + } +} + +export function saveNewGistDraft (storage, userLogin, data) { + try { + return storage.set( + getNewGistDraftStorageKey(userLogin), + createNewGistDraft(data) + ) + } catch (error) { + return createStorageFailure(error) + } +} + +export function createNewGistWithDraft ({ storage, userLogin, data, createGist }) { + const draftWrite = saveNewGistDraft(storage, userLogin, data) + + return Promise.resolve() + .then(createGist) + .then(gistDetails => ({ + status: 'created', + gistDetails, + draftWrite + }), error => ({ + status: 'failed', + error, + draftWrite + })) +} + +export function clearNewGistDraft (storage, userLogin) { + try { + return storage.set(getNewGistDraftStorageKey(userLogin), null) + } catch (error) { + return createStorageFailure(error) + } +} diff --git a/tests/utilities/i18n.test.js b/tests/utilities/i18n.test.js index d3e662de..c33d7450 100644 --- a/tests/utilities/i18n.test.js +++ b/tests/utilities/i18n.test.js @@ -111,6 +111,9 @@ describe('i18n utilities', () => { expect(t('menu.submitGist')).toBe('Submit Snippet') expect(t('menu.syncGist')).toBe('Sync Snippet') expect(t('notification.gistCreated')).toBe('Snippet created') + expect(t('notification.gistDraftSaved')).toBe( + 'Your draft was saved locally. Retry when your connection returns.' + ) expect(t('snippet.deleteConfirmTitle')).toBe('Delete the snippet?') }) diff --git a/tests/utilities/newGistDraft.test.js b/tests/utilities/newGistDraft.test.js new file mode 100644 index 00000000..40967281 --- /dev/null +++ b/tests/utilities/newGistDraft.test.js @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + clearNewGistDraft, + createNewGistDraft, + createNewGistWithDraft, + getNewGistDraftStorageKey, + loadNewGistDraft, + saveNewGistDraft +} from '../../app/utilities/newGistDraft' + +function createMemoryStorage (initialValues = {}) { + const values = Object.assign({}, initialValues) + + return { + values, + get: vi.fn(key => Object.prototype.hasOwnProperty.call(values, key) + ? { status: true, data: values[key] } + : { status: false }), + set: vi.fn((key, value) => { + values[key] = value + return { status: true, data: value } + }) + } +} + +describe('new snippet draft storage', () => { + it('normalizes editor submissions into restorable initial data', () => { + expect(createNewGistDraft({ + description: 'network-safe snippet', + private: true, + gistFiles: [ + { filename: 'app.js', content: 'console.log(1)', _editorId: 'editor-1' } + ] + })).toEqual({ + description: 'network-safe snippet', + private: true, + gists: [ + { filename: 'app.js', content: 'console.log(1)' } + ] + }) + }) + + it('scopes saved drafts to the signed-in user', () => { + const storage = createMemoryStorage() + const draft = { + description: 'octocat draft', + private: false, + gistFiles: [{ filename: 'draft.md', content: '# Draft' }] + } + + expect(saveNewGistDraft(storage, 'octocat', draft).status).toBe(true) + expect(loadNewGistDraft(storage, 'other-user')).toBeNull() + expect(loadNewGistDraft(storage, 'octocat')).toEqual({ + description: 'octocat draft', + private: false, + gists: [{ filename: 'draft.md', content: '# Draft' }] + }) + }) + + it('clears a draft only after the caller completes creation', () => { + const storage = createMemoryStorage() + const key = getNewGistDraftStorageKey('octocat') + + saveNewGistDraft(storage, 'octocat', { + description: 'saved before request', + gistFiles: [{ filename: 'draft.txt', content: 'keep me' }] + }) + + expect(storage.values[key]).toEqual(expect.objectContaining({ + description: 'saved before request' + })) + + expect(clearNewGistDraft(storage, 'octocat').status).toBe(true) + expect(storage.values[key]).toBeNull() + expect(loadNewGistDraft(storage, 'octocat')).toBeNull() + }) + + it('retains the local draft when creation fails', async () => { + const storage = createMemoryStorage() + const createGist = vi.fn().mockRejectedValue(new Error('offline')) + + const result = await createNewGistWithDraft({ + storage, + userLogin: 'octocat', + data: { + description: 'saved before request', + gistFiles: [{ filename: 'draft.txt', content: 'keep me' }] + }, + createGist + }) + + expect(result).toMatchObject({ + status: 'failed', + error: expect.objectContaining({ message: 'offline' }), + draftWrite: { status: true } + }) + expect(loadNewGistDraft(storage, 'octocat')).toEqual({ + description: 'saved before request', + private: false, + gists: [{ filename: 'draft.txt', content: 'keep me' }] + }) + }) + + it('returns the created gist while leaving draft cleanup to the success handler', async () => { + const storage = createMemoryStorage() + const gistDetails = { id: 'gist-1' } + + const result = await createNewGistWithDraft({ + storage, + userLogin: 'octocat', + data: { + description: 'created draft', + gistFiles: [{ filename: 'created.txt', content: 'created' }] + }, + createGist: () => Promise.resolve(gistDetails) + }) + + expect(result).toMatchObject({ + status: 'created', + gistDetails, + draftWrite: { status: true } + }) + expect(loadNewGistDraft(storage, 'octocat')).not.toBeNull() + }) + + it('ignores malformed drafts and reports storage write failures', () => { + const key = getNewGistDraftStorageKey('octocat') + const malformedStorage = createMemoryStorage({ + [key]: { description: 'missing files' } + }) + const failingStorage = { + set: () => { throw new Error('disk full') } + } + + expect(loadNewGistDraft(malformedStorage, 'octocat')).toBeNull() + expect(saveNewGistDraft(failingStorage, 'octocat', {})).toMatchObject({ + status: false, + error: expect.objectContaining({ message: 'disk full' }) + }) + expect(clearNewGistDraft(failingStorage, 'octocat')).toMatchObject({ + status: false, + error: expect.objectContaining({ message: 'disk full' }) + }) + }) +})