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' })
+ })
+ })
+})