Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/renderer/components/http/RequestsListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { HttpRequestListItem } from '@/composables/spaces/http/useHttpReque
import * as ContextMenu from '@/components/ui/shadcn/context-menu'
import { useHttpApp, useHttpRequests } from '@/composables'
import { useDateFormat } from '@/composables/useDateFormat'
import { useNavigationHistory } from '@/composables/useNavigationHistory'
import { i18n } from '@/electron'
import { onClickOutside } from '@vueuse/core'
import { CloudDownload } from 'lucide-vue-next'
Expand All @@ -21,7 +22,8 @@ const {
focusedRequestId,
httpState,
} = useHttpApp()
const { selectHttpRequest, selectedRequestIds } = useHttpRequests()
const { selectHttpRequest, selectedRequestIds, currentRequest }
= useHttpRequests()
const itemRef = ref<HTMLDivElement>()

const isSelected = computed(() => httpState.requestId === props.request.id)
Expand All @@ -40,8 +42,16 @@ const isCloudPending = computed(
() => props.request.pendingCloudDownload === true,
)

function onClick(event: MouseEvent) {
selectHttpRequest(props.request.id, event.shiftKey)
async function onClick(event: MouseEvent) {
if (event.shiftKey) {
await selectHttpRequest(props.request.id, true)
}
else {
await useNavigationHistory().recordNavigation(async () => {
await selectHttpRequest(props.request.id)
return currentRequest.value?.id === props.request.id
})
}
focusedRequestId.value = props.request.id
}

Expand Down
48 changes: 45 additions & 3 deletions src/renderer/components/notes/NotesEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -760,10 +760,52 @@ async function syncNavigationNoteUIStateRegistration(noteId = props.noteId) {
return
}

let restored = false
unregisterNavigationNoteUIState = registerNavigationNoteUIState(noteId, {
getScrollTop: () => view?.scrollDOM.scrollTop ?? 0,
setScrollTop: (scrollTop) => {
view?.scrollDOM.scrollTo({ top: scrollTop })
getScrollSnapshot: () => {
if (!view)
return
const anchor = view.lineBlockAtHeight(view.scrollDOM.scrollTop).from
const coordinates = view.coordsAtPos(anchor)
if (!coordinates)
return
return {
effect: markRaw(view.scrollSnapshot()),
anchor,
offset: coordinates.top - view.scrollDOM.getBoundingClientRect().top,
}
},
setScrollTop: (scrollTop, snapshot) => {
restored = true
if (snapshot && view) {
const editor = view
editor.dispatch({ effects: snapshot.effect })
// Preview decorations change line heights when the restored viewport is built.
requestAnimationFrame(() =>
editor.requestMeasure({
read: () => {
if (view !== editor || props.noteId !== noteId)
return 0
const coordinates = editor.coordsAtPos(
Math.min(snapshot.anchor, editor.state.doc.length),
)
return coordinates
? coordinates.top
- editor.scrollDOM.getBoundingClientRect().top
- snapshot.offset
: 0
},
write: (delta) => {
if (view === editor && props.noteId === noteId)
editor.scrollDOM.scrollTop += delta
},
}),
)
}
else {
view?.scrollDOM.scrollTo({ top: scrollTop })
}
},
})

Expand All @@ -777,7 +819,7 @@ async function syncNavigationNoteUIStateRegistration(noteId = props.noteId) {
// Back/Forward восстанавливает сохранённую позицию. При обычном выборе
// новая заметка открывается сверху. Эффект CodeMirror также пересчитывает
// viewport, чтобы preview-декорации сразу построились для начала документа.
if (!applyPendingNavigationUIStateForNote(noteId)) {
if (!applyPendingNavigationUIStateForNote(noteId) && !restored) {
view.dispatch({
effects: EditorView.scrollIntoView(0, { y: 'start', yMargin: 0 }),
})
Expand Down
12 changes: 9 additions & 3 deletions src/renderer/components/notes/NotesListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const props = defineProps<Props>()
const { formatDate } = useDateFormat()

const { isCompactListMode } = useApp()
const { clearHistory } = useNavigationHistory()
const { recordNavigation } = useNavigationHistory()
const { highlightedNoteIds, highlightedFolderIds, focusedNoteId, notesState }
= useNotesApp()

Expand Down Expand Up @@ -130,8 +130,14 @@ const trailingMeta = computed(() => {
return formatDate(new Date(props.note.updatedAt))
})
function onNoteClick(id: number, event: MouseEvent) {
clearHistory()
selectNote(id, event.shiftKey)
if (event.shiftKey) {
selectNote(id, true)
}
else {
recordNavigation(() => {
selectNote(id)
})
}
focusedNoteId.value = id
}

Expand Down
5 changes: 4 additions & 1 deletion src/renderer/components/notes/NotesPresentation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useNoteSearch,
useNotesSpaceInitialization,
} from '@/composables'
import { useNavigationHistory } from '@/composables/useNavigationHistory'
import { i18n } from '@/electron'
import { router, RouterName } from '@/router'
import { useFullscreen, useMagicKeys } from '@vueuse/core'
Expand Down Expand Up @@ -90,7 +91,9 @@ function onPrevNext(direction: 'prev' | 'next') {
const id = noteIds.value[targetIndex]

if (id !== undefined) {
selectNote(id)
useNavigationHistory().recordNavigation(() => {
selectNote(id)
})
}
}

Expand Down
12 changes: 9 additions & 3 deletions src/renderer/components/snippet/Item.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const {
updateSnippets,
deleteSelectedSnippets,
} = useSnippets()
const { clearHistory } = useNavigationHistory()
const { recordNavigation } = useNavigationHistory()

const { copy } = useClipboard()

Expand Down Expand Up @@ -99,8 +99,14 @@ const createdAtFormatted = computed(() =>
)

function onSnippetClick(id: number, event: MouseEvent) {
clearHistory()
selectSnippet(id, event.shiftKey)
if (event.shiftKey) {
selectSnippet(id, true)
}
else {
recordNavigation(() => {
selectSnippet(id)
})
}
focusedSnippetId.value = id
}

Expand Down
4 changes: 2 additions & 2 deletions src/renderer/components/space-rail/SpaceRail.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import * as Tooltip from '@/components/ui/shadcn/tooltip'
import { i18n, store } from '@/electron'
import { openSpaceTarget } from '@/ipc/listeners/deepLinks'
import { RouterName } from '@/router'
import { getSpaceDefinitions } from '@/spaceDefinitions'
import { isMac } from '@/utils'
Expand Down Expand Up @@ -37,7 +38,6 @@ watch(
<RouterLink
v-for="space in spaces"
:key="space.id"
v-slot="{ navigate }"
custom
:to="space.to"
>
Expand All @@ -53,7 +53,7 @@ watch(
"
:aria-label="space.label"
:aria-current="space.active ? 'page' : undefined"
@click="navigate"
@click="openSpaceTarget(space.id)"
>
<component
:is="space.icon"
Expand Down
187 changes: 187 additions & 0 deletions src/renderer/composables/__tests__/useNavigationHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ describe('useNavigationHistory', () => {
}))

vi.doMock('../useNavigationUIState', () => ({
clearPendingNavigationUIStateRestore: vi.fn(),
captureNavigationUIState: vi.fn((entry) => {
if (entry.type === 'note' && entry.id === 1) {
return { scrollTop: 280 }
Expand Down Expand Up @@ -688,3 +689,189 @@ describe('useNavigationHistory', () => {
expect(history.canGoForward.value).toBe(false)
})
})

describe('navigation transactions', () => {
async function setup() {
vi.resetModules()
vi.doUnmock('../useNavigationUIState')
const selectedNote = ref({ id: 1, name: 'A' })
vi.doMock('@/router', () => ({
RouterName: { notesSpace: 'notes' },
router: { currentRoute: ref({ name: 'notes' }) },
}))
vi.doMock('../spaces/notes/useNotes', () => ({
useNotes: () => ({ selectedNote }),
}))
vi.doMock('../useSnippets', () => ({
useSnippets: () => ({ selectedSnippet: ref() }),
}))
const { useNavigationHistory } = await import('../useNavigationHistory')
const history = useNavigationHistory()
const open = (id: number) => {
selectedNote.value = { id, name: String(id) }
}
return { history, open, selectedNote }
}

it('commits synchronous A B C immediately and preserves Forward on a no-op', async () => {
const { history, open } = await setup()
history.recordNavigation(() => open(2))
history.recordNavigation(() => open(3))
expect(
history.entries.value.map(entry => 'id' in entry && entry.id),
).toEqual([1, 2, 3])
history.goBack()
open(2)
history.recordNavigation(() => open(2))
expect(history.canGoForward.value).toBe(true)
expect(history.cursor.value).toBe(1)
})

it('ignores cancellation and invalidates an async record after clear', async () => {
const { history, open } = await setup()
history.recordNavigation(() => false)
expect(history.entries.value).toEqual([])
let finish!: () => void
const pending = history.recordNavigation(
() =>
new Promise<void>((resolve) => {
finish = resolve
}),
)
const next = history.recordNavigation(() => open(2))
finish()
await pending
await next
expect(history.entries.value).toHaveLength(2)
const pendingClear = history.recordNavigation(
() =>
new Promise<void>((resolve) => {
finish = resolve
}),
)
history.clearHistory()
finish()
await pendingClear
expect(history.entries.value).toEqual([])
})

it('finishes a deferred entity open before Back and leaves cursor on the visible target', async () => {
const { history, open, selectedNote } = await setup()
history.recordNavigation(() => open(2))
let resolveFetch!: () => void
const fetched = new Promise<void>((resolve) => {
resolveFetch = resolve
})
const opening = history.recordNavigation(async () => {
await fetched
open(3)
})
const restore = vi.fn(async (entry: { id?: number }) => {
open(entry.id!)
return 'restored' as const
})
const back = history.restoreHistory(-1, restore)
expect(restore).not.toHaveBeenCalled()
expect(selectedNote.value.id).toBe(2)
resolveFetch()
await opening
await back
expect(selectedNote.value.id).toBe(2)
expect(history.entries.value[history.cursor.value]).toMatchObject({
id: 2,
})
expect(
history.entries.value.map(entry => 'id' in entry && entry.id),
).toEqual([1, 2, 3])
})

it('serializes two ordinary opens with late mutations and continues after rejection', async () => {
const { history, open, selectedNote } = await setup()
let resolveFetch!: () => void
const fetched = new Promise<void>((resolve) => {
resolveFetch = resolve
})
const first = history.recordNavigation(async () => {
await fetched
open(2)
})
const secondCallback = vi.fn(async () => {
open(3)
})
const second = history.recordNavigation(secondCallback)
expect(secondCallback).not.toHaveBeenCalled()
resolveFetch()
await Promise.all([first, second])
expect(selectedNote.value.id).toBe(3)
expect(history.entries.value[history.cursor.value]).toMatchObject({
id: 3,
})
expect(
history.entries.value.map(entry => 'id' in entry && entry.id),
).toEqual([1, 2, 3])
const failed = history.recordNavigation(async () => {
throw new Error('offline')
})
const recovery = history.recordNavigation(() => open(4))
await expect(failed).rejects.toThrow('offline')
await recovery
expect(selectedNote.value.id).toBe(4)
expect(history.entries.value[history.cursor.value]).toMatchObject({
id: 4,
})
})

it('commits the cursor only on success and blocks concurrent replay and explicit opens', async () => {
const { history, open } = await setup()
history.recordNavigation(() => open(2))
let finish!: (result: 'restored') => void
const restore = vi.fn(
() =>
new Promise<'restored'>((resolve) => {
finish = resolve
}),
)
const pending = history.restoreHistory(-1, restore)
expect(history.cursor.value).toBe(1)
const explicitOpen = vi.fn()
history.recordNavigation(explicitOpen)
await history.restoreHistory(-1, restore)
expect(explicitOpen).not.toHaveBeenCalled()
expect(restore).toHaveBeenCalledOnce()
finish('restored')
await pending
expect(history.cursor.value).toBe(0)
})

it('prunes missing entries, continues backward and preserves the current entry when all are missing', async () => {
const { history, open } = await setup()
history.recordNavigation(() => open(2))
history.recordNavigation(() => open(3))
const restore = vi
.fn()
.mockResolvedValueOnce('missing')
.mockResolvedValueOnce('restored')
await history.restoreHistory(-1, restore)
expect(
history.entries.value.map(entry => 'id' in entry && entry.id),
).toEqual([1, 3])
expect(history.cursor.value).toBe(0)
await history.restoreHistory(1, async () => 'missing')
expect(history.entries.value).toHaveLength(1)
expect(history.cursor.value).toBe(0)
})

it('keeps history and cursor on cancelled or failed restoration', async () => {
const { history, open } = await setup()
history.recordNavigation(() => open(2))
await history.restoreHistory(-1, async () => 'cancelled')
await expect(
history.restoreHistory(-1, async () => {
throw new Error('offline')
}),
).rejects.toThrow('offline')
expect(history.cursor.value).toBe(1)
expect(history.entries.value).toHaveLength(2)
expect(history.isNavigatingHistory.value).toBe(false)
})
})
Loading