Skip to content

Commit 2aa265e

Browse files
fix(code): preserve focus and pending edits during snippet autosave
* fix(code): preserve editor focus when renaming snippets * fix(code): retain pending description edits when renaming
1 parent f8e11b9 commit 2aa265e

7 files changed

Lines changed: 165 additions & 18 deletions

File tree

‎src/renderer/components/editor/Description.vue‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,19 @@ const description = computed({
2121
}
2222
2323
addToUpdateQueue(selectedSnippet.value!.id, {
24-
name: selectedSnippet.value!.name,
2524
description: v,
26-
folderId: selectedSnippet.value!.folder?.id || null,
27-
isDeleted: selectedSnippet.value!.isDeleted,
28-
isFavorites: selectedSnippet.value!.isFavorites,
2925
})
3026
},
3127
})
3228
33-
watch(displayedSnippet, () => {
34-
if (show.value) {
35-
show.value = false
36-
}
37-
})
29+
watch(
30+
() => displayedSnippet.value?.id,
31+
() => {
32+
if (show.value) {
33+
show.value = false
34+
}
35+
},
36+
)
3837
</script>
3938

4039
<template>

‎src/renderer/components/editor/Editor.vue‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ const {
3737
displayedSnippetContent,
3838
selectedSnippetContent,
3939
selectedSnippet,
40-
isEmpty,
40+
isEmpty: isSnippetListEmpty,
41+
isSearch,
4142
selectedSnippetIds,
4243
selectedSnippetRecordStatus,
4344
isAvailableToCodePreview,
@@ -105,6 +106,9 @@ const scrollBarOpacity = useCssVar(
105106
},
106107
)
107108
109+
// Переименование может убрать последнее совпадение, но не должно закрывать редактор.
110+
const isEmpty = computed(() => isSnippetListEmpty.value && !isSearch.value)
111+
108112
const isShowHeader = computed(() => {
109113
if (selectedSnippetIds.value.length > 1)
110114
return false

‎src/renderer/components/editor/header/Header.vue‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,7 @@ const {
9191
}
9292
9393
addToUpdateQueue(selectedSnippet.value.id, {
94-
name: v,
95-
description: selectedSnippet.value.description,
96-
folderId: selectedSnippet.value.folder?.id || null,
97-
isDeleted: selectedSnippet.value.isDeleted,
98-
isFavorites: selectedSnippet.value.isFavorites,
94+
name: v.trim(),
9995
})
10096
},
10197
)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { ref } from 'vue'
3+
4+
globalThis.ref = ref
5+
6+
async function setup() {
7+
vi.resetModules()
8+
vi.useFakeTimers()
9+
const updateSnippet = vi.fn(async () => undefined)
10+
vi.doMock('../useSnippets', () => ({
11+
useSnippets: () => ({ updateSnippet, updateSnippetContent: vi.fn() }),
12+
}))
13+
vi.doMock('../useStorageMutation', () => ({ markUserEdit: vi.fn() }))
14+
vi.doMock('@/utils', () => ({ isRetriableSaveError: () => false }))
15+
const { useSnippetUpdate } = await import('../useSnippetUpdate')
16+
return { ...useSnippetUpdate(), updateSnippet }
17+
}
18+
19+
afterEach(() => {
20+
vi.clearAllTimers()
21+
vi.useRealTimers()
22+
})
23+
24+
describe('snippet metadata debounce', () => {
25+
it.each([true, false])(
26+
'preserves both fields when description is first: %s',
27+
async (descriptionFirst) => {
28+
const context = await setup()
29+
const changes = [
30+
{ description: 'New description' },
31+
{ name: 'New name' },
32+
]
33+
if (!descriptionFirst)
34+
changes.reverse()
35+
context.addToUpdateQueue(1, changes[0]!)
36+
await vi.advanceTimersByTimeAsync(200)
37+
context.addToUpdateQueue(1, changes[1]!)
38+
await vi.advanceTimersByTimeAsync(499)
39+
expect(context.updateSnippet).not.toHaveBeenCalled()
40+
await vi.advanceTimersByTimeAsync(1)
41+
expect(context.updateSnippet).toHaveBeenCalledExactlyOnceWith(1, {
42+
name: 'New name',
43+
description: 'New description',
44+
})
45+
},
46+
)
47+
48+
it('keeps the latest field value, including an empty description', async () => {
49+
const context = await setup()
50+
context.addToUpdateQueue(1, {
51+
description: 'Old description',
52+
name: 'First',
53+
})
54+
context.addToUpdateQueue(1, { description: '' })
55+
context.addToUpdateQueue(1, { name: 'Last' })
56+
await vi.advanceTimersByTimeAsync(500)
57+
expect(context.updateSnippet).toHaveBeenCalledExactlyOnceWith(1, {
58+
name: 'Last',
59+
description: '',
60+
})
61+
})
62+
63+
it('keeps updates for different snippets separate', async () => {
64+
const context = await setup()
65+
context.addToUpdateQueue(1, { description: 'A' })
66+
context.addToUpdateQueue(2, { name: 'B' })
67+
context.addToUpdateQueue(1, { name: 'A' })
68+
await vi.advanceTimersByTimeAsync(500)
69+
expect(context.updateSnippet).toHaveBeenCalledTimes(2)
70+
expect(context.updateSnippet).toHaveBeenCalledWith(1, {
71+
description: 'A',
72+
name: 'A',
73+
})
74+
expect(context.updateSnippet).toHaveBeenCalledWith(2, { name: 'B' })
75+
})
76+
})

‎src/renderer/composables/__tests__/useSnippetsSearch.test.ts‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ interface SetupOptions {
1212
folderId?: number
1313
libraryFilter?: string
1414
snippetId?: number
15+
sort?: 'name' | 'updatedAt'
16+
order?: 'ASC' | 'DESC'
1517
tagId?: number
1618
}
1719

@@ -56,7 +58,8 @@ async function setup(options: SetupOptions = {}) {
5658
// чтобы не тянуть electron store в тест.
5759
vi.doMock('@/composables/useContentSort', () => ({
5860
useContentSort: () => ({
59-
getContentSortQuery: () => ({}),
61+
getContentSortQuery: () =>
62+
options.sort ? { sort: options.sort, order: options.order } : {},
6063
}),
6164
}))
6265

@@ -349,3 +352,67 @@ describe('selected snippet full record', () => {
349352
consoleError.mockRestore()
350353
})
351354
})
355+
356+
describe('snippet rename refresh', () => {
357+
it.each(['name', 'updatedAt'] as const)(
358+
'refreshes %s order without reloading the selected record',
359+
async (sort) => {
360+
const context = await setup({ snippetId: 1, sort, order: 'ASC' })
361+
await context.snippets.refreshSelectedSnippet()
362+
await context.snippets.getSnippets()
363+
const fullRecordCalls = context.getSnippetsById.mock.calls.length
364+
context.getSnippets.mockResolvedValueOnce({
365+
data: [
366+
{ id: 2, name: 'B', contents: [], tags: [] },
367+
{ id: 1, name: 'Z', contents: [], tags: [] },
368+
],
369+
})
370+
371+
await context.snippets.updateSnippet(1, { name: 'Z' })
372+
373+
expect(context.getSnippets).toHaveBeenLastCalledWith(
374+
expect.objectContaining({ sort, order: 'ASC' }),
375+
)
376+
expect(
377+
context.snippets.displayedSnippets.value?.map(item => item.id),
378+
).toEqual([2, 1])
379+
expect(context.snippets.selectedSnippet.value?.name).toBe('Z')
380+
expect(context.snippets.displayedSnippetContent.value?.value).toBe(
381+
'Content 1',
382+
)
383+
expect(context.snippets.selectedSnippetRecordStatus.value).toBe('ready')
384+
expect(context.getSnippetsById).toHaveBeenCalledTimes(fullRecordCalls)
385+
},
386+
)
387+
388+
it('removes and restores a search result while retaining the open record', async () => {
389+
const context = await setup({ snippetId: 1 })
390+
context.snippets.searchQuery.value = 'needle'
391+
await context.snippets.search()
392+
await context.snippets.refreshSelectedSnippet()
393+
const fullRecordCalls = context.getSnippetsById.mock.calls.length
394+
context.getSnippets.mockResolvedValueOnce({ data: [] })
395+
396+
await context.snippets.updateSnippet(1, { name: 'Renamed' })
397+
398+
expect(context.getSnippets).toHaveBeenLastCalledWith({ search: 'needle' })
399+
expect(context.snippets.displayedSnippets.value).toEqual([])
400+
expect(context.state.snippetId).toBe(1)
401+
expect(context.state.snippetContentIndex).toBe(0)
402+
expect(context.snippets.displayedSnippet.value?.name).toBe('Renamed')
403+
expect(context.snippets.displayedSnippetContent.value?.value).toBe(
404+
'Content 1',
405+
)
406+
expect(context.snippets.selectedSnippetRecordStatus.value).toBe('ready')
407+
408+
context.getSnippets.mockResolvedValueOnce({
409+
data: [{ id: 1, name: 'needle again', contents: [], tags: [] }],
410+
})
411+
await context.snippets.updateSnippet(1, { name: 'needle again' })
412+
413+
expect(
414+
context.snippets.displayedSnippets.value?.map(item => item.id),
415+
).toEqual([1])
416+
expect(context.getSnippetsById).toHaveBeenCalledTimes(fullRecordCalls)
417+
})
418+
})

‎src/renderer/composables/useSnippetUpdate.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,10 @@ function scheduleContentUpdate(key: string, delayMs = UPDATE_DEBOUNCE_TIME) {
146146
function addToUpdateQueue(snippetId: number, data: SnippetsUpdate) {
147147
markUserEdit()
148148
const key = `${snippetId}`
149-
updateQueue.value.set(key, { snippetId, data })
149+
updateQueue.value.set(key, {
150+
snippetId,
151+
data: { ...updateQueue.value.get(key)?.data, ...data },
152+
})
150153
// Новый ввод сбрасывает backoff: пользователь активен, сохранение снова
151154
// пробуется быстро.
152155
retryAttemptsByKey.value.delete(key)

‎src/renderer/composables/useSnippets.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -531,8 +531,10 @@ async function updateSnippet(snippetId: number, data: SnippetsUpdate) {
531531
return
532532
}
533533

534-
// Переименование/описание не меняют состав списка — обновляем точечно.
534+
// Полную запись обновляем без loading, чтобы не сбрасывать фокус редактора.
535535
patchSnippetInCollections(snippetId, data)
536+
// Название/описание влияют на поиск, а сохранение — на порядок updatedAt.
537+
await getSnippets()
536538
}
537539

538540
async function updateSnippets(snippetIds: number[], data: SnippetsUpdate[]) {

0 commit comments

Comments
 (0)