diff --git a/app/containers/gistEditorForm/index.js b/app/containers/gistEditorForm/index.js index 295d7e2b..bc0f881a 100644 --- a/app/containers/gistEditorForm/index.js +++ b/app/containers/gistEditorForm/index.js @@ -169,7 +169,7 @@ class GistEditorForm extends Component { } render () { - const { handleCancel, formStyle } = this.props + const { footerHelper, handleCancel, formStyle } = this.props const { values, errors, touched, submitAttempted, submitting } = this.state return ( @@ -197,19 +197,25 @@ class GistEditorForm extends Component { }) }
+ { footerHelper && ( +
+ + { footerHelper } +
+ ) }
diff --git a/app/containers/gistEditorForm/index.scss b/app/containers/gistEditorForm/index.scss index cab3a1f4..2d6ececa 100644 --- a/app/containers/gistEditorForm/index.scss +++ b/app/containers/gistEditorForm/index.scss @@ -85,7 +85,21 @@ } .control-button-group { + align-items: center; + display: flex; height: 45px; + justify-content: flex-end; + } + + .gist-editor-footer-helper { + @extend .font-style-base; + align-items: center; + color: var(--text-secondary); + display: flex; + font-size: 12px; + gap: 5px; + margin-right: auto; + min-width: 0; } .gist-editor-customized-tag { diff --git a/app/containers/userPanel/index.js b/app/containers/userPanel/index.js index 6f85e7ba..7f51742a 100644 --- a/app/containers/userPanel/index.js +++ b/app/containers/userPanel/index.js @@ -8,7 +8,7 @@ import Modal from '../compatModal' import { notifySuccess, notifyFailure } from '../../utilities/notifier' import React, { Component } from 'react' import { subscribeIpc, unsubscribeIpc } from '../../utilities/ipcSubscriptions' -import { t } from '../../utilities/i18n' +import { getLocale, t } from '../../utilities/i18n' import { addLangPrefix as Prefixed, descriptionParser, @@ -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,30 @@ const kIsPrivate = conf.get('snippet:newSnippetPrivate') const hideProfilePhoto = conf.get('userPanel:hideProfilePhoto') class UserPanel extends Component { + constructor (props) { + super(props) + const newGistDraft = loadNewGistDraft( + electronBridge.localStorage, + this.getUserLogin(props) + ) + this.newGistInitialData = this.createEmptyNewGistData() + this.state = { + newGistDraft, + newGistDraftLoaded: false + } + } + + 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 +104,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 +114,49 @@ 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)) + if (result.draftWrite && result.draftWrite.status) { + this.setState({ + newGistDraft: result.draftWrite.data, + newGistDraftLoaded: true + }) + } + 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.setState({ + newGistDraft: null, + newGistDraftLoaded: false }) + + this.closeGistEditorModal() + }) } updateGistsStoreWithNewGist (gistDetails) { @@ -159,22 +224,84 @@ class UserPanel extends Component { } renderGistEditorModalBody () { - const initialData = { - description: '', - private: kIsPrivate, - gists: [ - { filename: '', content: '' } - ] - } + const showDraftReplacementWarning = Boolean( + this.state.newGistDraft && !this.state.newGistDraftLoaded + ) + return ( ) } + formatNewGistDraftCreatedAt () { + return new Intl.DateTimeFormat(getLocale(), { + dateStyle: 'medium', + timeStyle: 'short' + }).format(new Date(this.state.newGistDraft.createdAt)) + } + + handleLoadNewGistDraft () { + this.newGistInitialData = createNewGistDraft(this.state.newGistDraft) + this.setState({ newGistDraftLoaded: true }) + } + + handleDropNewGistDraft () { + const draftClear = clearNewGistDraft( + electronBridge.localStorage, + this.getUserLogin() + ) + if (!draftClear || !draftClear.status) { + logger.error('Failed to drop the saved new snippet draft') + return + } + + if (this.state.newGistDraftLoaded) { + this.newGistInitialData = this.createEmptyNewGistData() + } + this.setState({ + newGistDraft: null, + newGistDraftLoaded: false + }) + } + + renderNewGistDraftCallout () { + if (!this.state.newGistDraft) return null + + return ( +
+ +
+ { this.state.newGistDraftLoaded + ? t('editor.localDraftLoaded') + : t('editor.localDraftAvailable') } + { t('editor.localDraftCreatedAt', { + timestamp: this.formatNewGistDraftCreatedAt() + }) } +
+
+ { !this.state.newGistDraftLoaded && ( + + ) } + +
+
+ ) + } + renderGistEditorModal () { return ( { t('userPanel.new') } + { this.renderNewGistDraftCallout() } { this.renderGistEditorModalBody.bind(this)() } diff --git a/app/containers/userPanel/index.scss b/app/containers/userPanel/index.scss index c004f087..5a509676 100644 --- a/app/containers/userPanel/index.scss +++ b/app/containers/userPanel/index.scss @@ -69,6 +69,50 @@ .new-modal { width: 98vw; + + .new-gist-draft-callout { + align-items: center; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 3px; + color: var(--text-primary); + display: flex; + margin: 0 5px 15px; + min-height: 64px; + padding: 10px 14px; + } + + .new-gist-draft-icon { + font-size: 26px; + line-height: 1; + margin-right: 12px; + } + + .new-gist-draft-summary { + display: flex; + flex: 1 1 auto; + flex-direction: column; + font-size: 13px; + line-height: 1.5; + } + + .new-gist-draft-actions { + display: flex; + flex: 0 0 auto; + gap: 20px; + + button { + background: transparent; + border: 0; + color: #4078C0; + cursor: pointer; + padding: 5px; + } + + .drop-new-gist-draft { + color: #B94A48; + } + } } /* Global setting affecting all dialogs (NEW/EDIT) if not overwritten. */ diff --git a/app/index.js b/app/index.js index deb97d00..9a011b13 100644 --- a/app/index.js +++ b/app/index.js @@ -904,6 +904,9 @@ const renderFixture = getRenderFixture(getRenderFixtureName()) if (renderFixture) { logger.info(`[render-fixture] Rendering ${renderFixture.name} with mock state`) SearchIndex.resetFuseIndex(renderFixture.searchIndexRecords) + Object.entries(renderFixture.localStorage || {}).forEach(([key, value]) => { + electronBridge.localStorage.set(key, value) + }) } const reduxStore = renderFixture diff --git a/app/renderFixtures.js b/app/renderFixtures.js index 71e329b7..f71a486b 100644 --- a/app/renderFixtures.js +++ b/app/renderFixtures.js @@ -1,5 +1,6 @@ import { addLangPrefix as Prefixed } from './utilities/parser' import leptonLogoImage from './containers/aboutPage/logo-light.webp?inline' +import { getNewGistDraftStorageKey } from './utilities/newGistDraft' import SearchIndex from './utilities/search' const FIXTURE_USER = { @@ -295,6 +296,8 @@ function getFixtureOverrides (name) { return { logoutModalStatus: 'ON' } case 'new': return { gistNewModalStatus: 'ON' } + case 'new-draft': + return { gistNewModalStatus: 'ON' } case 'pinned-tags': return { pinnedTagsModalStatus: 'ON' } case 'php-html': @@ -403,6 +406,19 @@ export function getRenderFixture (name) { return { initialSearchQuery, + localStorage: name === 'new-draft' + ? { + [getNewGistDraftStorageKey(FIXTURE_USER.login)]: { + createdAt: '2026-09-06T21:35:00.000Z', + description: 'Recovered API helper', + private: true, + gists: [{ + filename: 'recovered.js', + content: 'const recovered = true' + }] + } + } + : null, name, searchIndexRecords, state: Object.assign({}, getBaseState(), stateOverrides) diff --git a/app/utilities/i18n/locales/en.js b/app/utilities/i18n/locales/en.js index cb5ef883..24f3dd09 100644 --- a/app/utilities/i18n/locales/en.js +++ b/app/utilities/i18n/locales/en.js @@ -33,8 +33,14 @@ module.exports = { addFile: '#add file', cancel: 'Cancel', descriptionPlaceholder: '[title] description #tag1 #tag2', + dropLocalDraft: 'Drop draft', filenamePlaceholder: 'file name... (e.g. snippet.js)', invalidFilename: 'invalid filename', + loadLocalDraft: 'Load draft', + localDraftAvailable: 'Local draft available', + localDraftCreatedAt: 'Created {{timestamp}}', + localDraftLoaded: 'Local draft loaded', + localDraftSubmitWarning: 'Submitting without loading the local draft will discard it.', removeFile: '#remove', required: 'required', secret: 'secret', @@ -105,6 +111,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..838602be 100644 --- a/app/utilities/i18n/locales/es.js +++ b/app/utilities/i18n/locales/es.js @@ -33,8 +33,14 @@ module.exports = { addFile: '#agregar archivo', cancel: 'Cancelar', descriptionPlaceholder: '[titulo] descripcion #etiqueta1 #etiqueta2', + dropLocalDraft: 'Descartar borrador', filenamePlaceholder: 'nombre de archivo... (ej. snippet.js)', invalidFilename: 'nombre de archivo invalido', + loadLocalDraft: 'Cargar borrador', + localDraftAvailable: 'Borrador local disponible', + localDraftCreatedAt: 'Creado {{timestamp}}', + localDraftLoaded: 'Borrador local cargado', + localDraftSubmitWarning: 'Enviar sin cargar el borrador local lo descartara.', removeFile: '#eliminar', required: 'obligatorio', secret: 'secreto', @@ -105,6 +111,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..dac13b08 100644 --- a/app/utilities/i18n/locales/fr.js +++ b/app/utilities/i18n/locales/fr.js @@ -33,8 +33,14 @@ module.exports = { addFile: '#ajouter fichier', cancel: 'Annuler', descriptionPlaceholder: '[titre] description #tag1 #tag2', + dropLocalDraft: 'Supprimer le brouillon', filenamePlaceholder: 'nom du fichier... (ex. snippet.js)', invalidFilename: 'nom de fichier invalide', + loadLocalDraft: 'Charger le brouillon', + localDraftAvailable: 'Brouillon local disponible', + localDraftCreatedAt: 'Cree le {{timestamp}}', + localDraftLoaded: 'Brouillon local charge', + localDraftSubmitWarning: 'Envoyer sans charger le brouillon local le supprimera.', removeFile: '#retirer', required: 'obligatoire', secret: 'prive', @@ -105,6 +111,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..778b794f 100644 --- a/app/utilities/i18n/locales/ja.js +++ b/app/utilities/i18n/locales/ja.js @@ -33,8 +33,14 @@ module.exports = { addFile: '#ファイルを追加', cancel: 'キャンセル', descriptionPlaceholder: '[タイトル] 説明 #タグ1 #タグ2', + dropLocalDraft: '下書きを破棄', filenamePlaceholder: 'ファイル名...(例: snippet.js)', invalidFilename: '無効なファイル名', + loadLocalDraft: '下書きを読み込む', + localDraftAvailable: 'ローカル下書きがあります', + localDraftCreatedAt: '作成日時: {{timestamp}}', + localDraftLoaded: 'ローカル下書きを読み込みました', + localDraftSubmitWarning: 'ローカル下書きを読み込まずに送信すると、その下書きは破棄されます。', removeFile: '#削除', required: '必須', secret: 'シークレット', @@ -105,6 +111,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..6215c8a9 100644 --- a/app/utilities/i18n/locales/ko.js +++ b/app/utilities/i18n/locales/ko.js @@ -33,8 +33,14 @@ module.exports = { addFile: '#파일 추가', cancel: '취소', descriptionPlaceholder: '[제목] 설명 #태그1 #태그2', + dropLocalDraft: '초안 버리기', filenamePlaceholder: '파일 이름... (예: snippet.js)', invalidFilename: '잘못된 파일 이름', + loadLocalDraft: '초안 불러오기', + localDraftAvailable: '로컬 초안 사용 가능', + localDraftCreatedAt: '{{timestamp}}에 생성됨', + localDraftLoaded: '로컬 초안을 불러왔습니다', + localDraftSubmitWarning: '로컬 초안을 불러오지 않고 제출하면 해당 초안이 삭제됩니다.', removeFile: '#제거', required: '필수', secret: '비공개', @@ -105,6 +111,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..f02d5e37 100644 --- a/app/utilities/i18n/locales/tr.js +++ b/app/utilities/i18n/locales/tr.js @@ -33,8 +33,14 @@ module.exports = { addFile: '#dosya ekle', cancel: 'İptal', descriptionPlaceholder: '[başlık] açıklama #etiket1 #etiket2', + dropLocalDraft: 'Taslağı sil', filenamePlaceholder: 'dosya adı... (örn. snippet.js)', invalidFilename: 'geçersiz dosya adı', + loadLocalDraft: 'Taslağı yükle', + localDraftAvailable: 'Yerel taslak mevcut', + localDraftCreatedAt: '{{timestamp}} tarihinde oluşturuldu', + localDraftLoaded: 'Yerel taslak yüklendi', + localDraftSubmitWarning: 'Yerel taslağı yüklemeden gönderirseniz taslak silinir.', removeFile: '#kaldır', required: 'gerekli', secret: 'gizli', @@ -105,6 +111,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..d5ee5296 100644 --- a/app/utilities/i18n/locales/zh-Hans.js +++ b/app/utilities/i18n/locales/zh-Hans.js @@ -33,8 +33,14 @@ module.exports = { addFile: '#添加文件', cancel: '取消', descriptionPlaceholder: '[标题] 描述 #标签1 #标签2', + dropLocalDraft: '丢弃草稿', filenamePlaceholder: '文件名...(例如 snippet.js)', invalidFilename: '无效文件名', + loadLocalDraft: '加载草稿', + localDraftAvailable: '有本地草稿', + localDraftCreatedAt: '创建于 {{timestamp}}', + localDraftLoaded: '已加载本地草稿', + localDraftSubmitWarning: '不加载本地草稿就提交会将其丢弃。', removeFile: '#移除', required: '必填', secret: '私密', @@ -105,6 +111,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..5cbb053d 100644 --- a/app/utilities/i18n/locales/zh-Hant.js +++ b/app/utilities/i18n/locales/zh-Hant.js @@ -33,8 +33,14 @@ module.exports = { addFile: '#新增檔案', cancel: '取消', descriptionPlaceholder: '[標題] 描述 #標籤1 #標籤2', + dropLocalDraft: '捨棄草稿', filenamePlaceholder: '檔案名稱...(例如 snippet.js)', invalidFilename: '無效的檔案名稱', + loadLocalDraft: '載入草稿', + localDraftAvailable: '有本機草稿', + localDraftCreatedAt: '建立於 {{timestamp}}', + localDraftLoaded: '已載入本機草稿', + localDraftSubmitWarning: '未載入本機草稿就提交會將其捨棄。', removeFile: '#移除', required: '必填', secret: '私密', @@ -105,6 +111,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..a27db073 --- /dev/null +++ b/app/utilities/newGistDraft.js @@ -0,0 +1,103 @@ +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 : '' + })) + : [] + + const draft = { + description: typeof data.description === 'string' ? data.description : '', + private: Boolean(data.private), + gists: gists.length ? gists : [{ filename: '', content: '' }] + } + + if (typeof data.createdAt === 'string' && !Number.isNaN(Date.parse(data.createdAt))) { + draft.createdAt = data.createdAt + } + + return draft +} + +function isNewGistDraft (draft) { + return Boolean( + draft && + typeof draft === 'object' && + typeof draft.description === 'string' && + typeof draft.private === 'boolean' && + typeof draft.createdAt === 'string' && + !Number.isNaN(Date.parse(draft.createdAt)) && + 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, createdAt = new Date().toISOString()) { + try { + return storage.set( + getNewGistDraftStorageKey(userLogin), + createNewGistDraft(Object.assign({}, data, { createdAt })) + ) + } 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/docs/img/pr-669/draft-available.png b/docs/img/pr-669/draft-available.png new file mode 100644 index 00000000..dbb2766f Binary files /dev/null and b/docs/img/pr-669/draft-available.png differ diff --git a/docs/img/pr-669/draft-loaded.png b/docs/img/pr-669/draft-loaded.png new file mode 100644 index 00000000..fc348ec2 Binary files /dev/null and b/docs/img/pr-669/draft-loaded.png differ diff --git a/tests/smoke/electron-render-smoke-main.js b/tests/smoke/electron-render-smoke-main.js index fdfe9028..a0dc3e51 100644 --- a/tests/smoke/electron-render-smoke-main.js +++ b/tests/smoke/electron-render-smoke-main.js @@ -771,6 +771,70 @@ async function assertFixturePageFind (window) { } } +async function assertFixtureNewDraftRecovery (window) { + if (process.env.LEPTON_RENDER_FIXTURE !== 'new-draft') return + + const recoveryState = await window.webContents.executeJavaScript(` + new Promise(resolve => { + const description = document.querySelector('.gist-editor-input-area') + const loadButton = Array.from(document.querySelectorAll('.new-gist-draft-actions button')) + .find(button => button.textContent.trim() === 'Load draft') + const initialState = { + description: description ? description.value : null, + hasHelper: document.body.innerText.includes('Submitting without loading the local draft will discard it.') + } + + loadButton.click() + setTimeout(() => { + const loadedDescription = document.querySelector('.gist-editor-input-area') + const loadedFilename = document.querySelector('.gist-editor-filename-area') + const editor = document.querySelector('.CodeMirror') + const loadedState = { + content: editor && editor.CodeMirror ? editor.CodeMirror.getValue() : null, + description: loadedDescription ? loadedDescription.value : null, + filename: loadedFilename ? loadedFilename.value : null, + hasHelper: document.body.innerText.includes('Submitting without loading the local draft will discard it.'), + hasLoadedStatus: document.body.innerText.includes('Local draft loaded') + } + resolve({ initialState, loadedState }) + }, 100) + }) + `, true) + + if ( + recoveryState.initialState.description !== '' || + !recoveryState.initialState.hasHelper || + recoveryState.loadedState.description !== 'Recovered API helper' || + recoveryState.loadedState.filename !== 'recovered.js' || + recoveryState.loadedState.content !== 'const recovered = true' || + recoveryState.loadedState.hasHelper || + !recoveryState.loadedState.hasLoadedStatus + ) { + throw new Error(`Expected local draft load/drop flow to preserve normal editor content: ${JSON.stringify(recoveryState)}`) + } + + await captureScreenshot(window, 'electron-render-new-draft-loaded.png') + + const droppedState = await window.webContents.executeJavaScript(` + new Promise(resolve => { + const dropButton = Array.from(document.querySelectorAll('.new-gist-draft-actions button')) + .find(button => button.textContent.trim() === 'Drop draft') + dropButton.click() + setTimeout(() => { + const description = document.querySelector('.gist-editor-input-area') + resolve({ + description: description ? description.value : null, + hasCallout: Boolean(document.querySelector('.new-gist-draft-callout')) + }) + }, 100) + }) + `, true) + + if (droppedState.description !== '' || droppedState.hasCallout) { + throw new Error(`Expected dropping a loaded local draft to reset the editor: ${JSON.stringify(droppedState)}`) + } +} + async function main () { let window @@ -784,6 +848,10 @@ async function main () { assertFixtureRendererState(await getRendererState(window)) await assertFixtureLoginModeSwitch(window) await assertFixturePageFind(window) + if (process.env.LEPTON_RENDER_FIXTURE === 'new-draft') { + await captureScreenshot(window, 'electron-render-new-draft-before-actions.png') + } + await assertFixtureNewDraftRecovery(window) await captureScreenshot(window, `electron-render-${process.env.LEPTON_RENDER_FIXTURE}-success.png`) console.log(`electron render fixture smoke test passed: ${process.env.LEPTON_RENDER_FIXTURE}`) } else { diff --git a/tests/smoke/electron-render-smoke.js b/tests/smoke/electron-render-smoke.js index 8edaa622..00919c6d 100644 --- a/tests/smoke/electron-render-smoke.js +++ b/tests/smoke/electron-render-smoke.js @@ -48,6 +48,11 @@ const RENDER_FIXTURES = [ selector: '.modal .gist-editor-form .CodeMirror', text: 'New' }, + { + name: 'new-draft', + selector: '.new-gist-draft-callout', + text: 'Local draft available|Created Sep 6, 2026|Load draft|Drop draft|Submitting without loading the local draft will discard it.' + }, { name: 'about', selector: '.about-modal .modal-title', diff --git a/tests/utilities/i18n.test.js b/tests/utilities/i18n.test.js index d3e662de..ba091483 100644 --- a/tests/utilities/i18n.test.js +++ b/tests/utilities/i18n.test.js @@ -111,6 +111,15 @@ 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('editor.localDraftCreatedAt', { timestamp: 'Sep 6, 2026, 2:35 PM' })).toBe( + 'Created Sep 6, 2026, 2:35 PM' + ) + expect(t('editor.localDraftSubmitWarning')).toBe( + 'Submitting without loading the local draft will discard it.' + ) 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..c26b2114 --- /dev/null +++ b/tests/utilities/newGistDraft.test.js @@ -0,0 +1,150 @@ +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', () => { + const createdAt = '2026-09-06T21:35:00.000Z' + + 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, createdAt).status).toBe(true) + expect(loadNewGistDraft(storage, 'other-user')).toBeNull() + expect(loadNewGistDraft(storage, 'octocat')).toEqual({ + description: 'octocat draft', + private: false, + createdAt, + 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' }] + }, createdAt) + + 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, + createdAt: expect.any(String), + 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' }) + }) + }) +})