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
82 changes: 80 additions & 2 deletions frontend/apps/desktop/src/components/document-actions-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,25 @@ import {DocumentActionsProvider} from '@shm/shared/document-actions-context'
import {queryResource} from '@shm/shared/models/queries'
import {invalidateQueries, queryClient} from '@shm/shared/models/query-client'
import {queryKeys} from '@shm/shared/models/query-keys'
import {hmId, pathMatches} from '@shm/shared/utils/entity-id-url'
import {useNavigate} from '@shm/shared/utils/navigation'
import {replaceRouteDocumentId} from '@shm/shared/routes'
import {hmId, latestId, pathMatches} from '@shm/shared/utils/entity-id-url'
import {useNavigate, useNavRoute} from '@shm/shared/utils/navigation'
import {SizableText} from '@shm/ui/text'
import {useAppDialog} from '@shm/ui/universal-dialog'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid'
import {PropsWithChildren, useCallback, useMemo} from 'react'
import {ResourceVisibility} from '@shm/shared/client/.generated/documents/v3alpha/documents_pb'
import {buildRestoreVersionChanges, getRestoreVersionGeneration} from '@shm/shared/utils/restore-document-version'
import {hmIdPathToEntityQueryPath} from '@shm/shared/utils/path-api'
import {toast} from 'sonner'

export function DesktopDocumentActionsProvider({children}: PropsWithChildren) {
const selectedAccountId = useSelectedAccountId()
const myAccountIds = useMyAccountIds()
const bookmarks = useBookmarks()
const navigate = useNavigate()
const currentRoute = useNavRoute()
const {exportDocument, openDirectory} = useAppContext()
const {onCopyReference} = useUniversalAppContext()
const universalClient = useUniversalClient()
Expand Down Expand Up @@ -197,6 +202,77 @@ export function DesktopDocumentActionsProvider({children}: PropsWithChildren) {

const getDraftId = useCallback((id: UnpackedHypermediaId) => getDraft(id)?.id, [getDraft])

const onRestoreDocumentVersion = useCallback(
async (id: UnpackedHypermediaId, selectedVersion: HMDocument) => {
if (!selectedAccountId) {
toast.error('Select an account before restoring a version')
return
}
if (!universalClient.publishDocument) {
toast.error('Restore is not available in this client')
return
}

try {
const targetId = latestId(id)
const latestResource = await queryClient.fetchQuery(queryResource(universalClient, targetId))
const latestDocument = latestResource?.type === 'document' ? latestResource.document : null
if (!latestDocument?.version) throw new Error('Could not load the latest document version')
if (latestDocument.version === selectedVersion.version) {
toast.info('This version is already the latest version')
return
}

const changes = buildRestoreVersionChanges(latestDocument, selectedVersion)
if (!changes.length) {
toast.info('This version matches the latest version')
return
}

let capability = ''
if (selectedAccountId !== targetId.uid) {
const result = await universalClient.request('ListCapabilities', {targetId})
const rawCapability = result.capabilities.find(
(cap: any) => cap.delegate === selectedAccountId && String(cap.role || '').toUpperCase() === 'WRITER',
)
if (!rawCapability?.id) throw new Error('Could not find write capability for selected account')
capability = rawCapability.id
}

await universalClient.publishDocument({
signerAccountUid: selectedAccountId,
account: targetId.uid,
path: hmIdPathToEntityQueryPath(targetId.path || []),
baseVersion: latestDocument.version,
changes,
capability,
visibility: ResourceVisibility.UNSPECIFIED,
genesis: latestDocument.genesis,
generation: getRestoreVersionGeneration(latestDocument),
})

const draftId = getDraftId(targetId)
if (draftId) {
await client.drafts.delete.mutate(draftId)
}

invalidateQueries([queryKeys.ENTITY])
invalidateQueries([queryKeys.ACTIVITY_FEED])
invalidateQueries([queryKeys.DRAFTS_LIST])
invalidateQueries([queryKeys.DRAFTS_LIST_ACCOUNT])
if (draftId) invalidateQueries([queryKeys.DRAFT, draftId])

navigate(replaceRouteDocumentId(currentRoute, targetId))
toast.success('Document restored successfully')
} catch (error) {
console.error('Failed to restore document version:', error)
toast.error(error instanceof Error ? error.message : 'Failed to restore document version')
throw error
}
},
[currentRoute, getDraftId, navigate, selectedAccountId, universalClient],
)

const value = useMemo(
() => ({
selectedAccountUid: selectedAccountId ?? undefined,
Expand All @@ -208,6 +284,7 @@ export function DesktopDocumentActionsProvider({children}: PropsWithChildren) {
onDeleteDocument,
onBranchDocument,
onDuplicateDocument,
onRestoreDocumentVersion,
onExportDocument,
onCopyLink,
getDraftId,
Expand All @@ -223,6 +300,7 @@ export function DesktopDocumentActionsProvider({children}: PropsWithChildren) {
onDeleteDocument,
onBranchDocument,
onDuplicateDocument,
onRestoreDocumentVersion,
onExportDocument,
onCopyLink,
getDraftId,
Expand Down
3 changes: 2 additions & 1 deletion frontend/apps/desktop/src/components/editing-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
DraftActionsToolbar as SharedDraftActionsToolbar,
EditingDocToolsRight as SharedEditingDocToolsRight,
} from '@shm/ui/editing-toolbar'
import {createDocumentVersionsPanelRoute} from '@shm/ui/document-versions-panel'
import {MenuItemType} from '@shm/ui/options-dropdown'
import {toast} from '@shm/ui/toast'
import {useCallback, useMemo} from 'react'
Expand Down Expand Up @@ -103,7 +104,7 @@ export function useDesktopToolbarCallbacks(docId: UnpackedHypermediaId): {
navigate({
key: 'document',
id,
panel: {key: 'activity', id, filterEventType: ['Ref']},
panel: createDocumentVersionsPanelRoute(id),
} as any)
},
}),
Expand Down
3 changes: 2 additions & 1 deletion frontend/apps/desktop/src/pages/desktop-resource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {useNavigationDispatch, useNavRoute} from '@shm/shared/utils/navigation'
import {entityQueryPathToHmIdPath} from '@shm/shared/utils/path-api'
import {CloudOff, Download, Trash, UploadCloud} from '@shm/ui/icons'
import {copyUrlToClipboardWithFeedback} from '@shm/ui/copy-to-clipboard'
import {createDocumentVersionsPanelRoute} from '@shm/ui/document-versions-panel'
import {MenuItemType} from '@shm/ui/options-dropdown'
import {ResourcePage} from '@shm/ui/resource-page-common'
import {SizableText} from '@shm/ui/text'
Expand Down Expand Up @@ -719,7 +720,7 @@ export default function DesktopResourcePage() {
replace({
key: 'document',
id: docId,
panel: {key: 'activity', id: docId, filterEventType: ['Ref']},
panel: createDocumentVersionsPanelRoute(docId),
})
},
})
Expand Down
67 changes: 67 additions & 0 deletions frontend/apps/desktop/src/utils/__tests__/publish-document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,32 @@ describe('shouldUseDaemonCreateDocumentChange', () => {
}),
).toBe(false)
})

it('keeps explicit-generation publishes on the seed client path', () => {
expect(
shouldUseDaemonCreateDocumentChange({
signerAccountUid: 'alice',
account: 'alice',
path: '/foo',
baseVersion: 'bafy-base',
genesis: 'bafy-genesis',
generation: 123,
changes: [],
}),
).toBe(false)
})

it('still uses daemon createDocumentChange for brand-new home documents with explicit generation', () => {
expect(
shouldUseDaemonCreateDocumentChange({
signerAccountUid: 'alice',
account: 'alice',
path: '',
generation: 123,
changes: [],
}),
).toBe(true)
})
})

describe('createDocumentChangeRequest', () => {
Expand Down Expand Up @@ -185,4 +211,45 @@ describe('publishDesktopDocument', () => {
signer,
)
})

it('passes explicit generation through the seed client path', async () => {
const createDocumentChange = vi.fn().mockResolvedValue(undefined)
const publishDocument = vi.fn().mockResolvedValue(undefined)
const signer = {
getPublicKey: vi.fn(async () => new Uint8Array([1])),
sign: vi.fn(async () => new Uint8Array([2])),
}
const getSigner = vi.fn(() => signer)

await publishDesktopDocument(
{
createDocumentChange,
publishDocument,
getSigner,
},
{
signerAccountUid: 'alice',
account: 'alice',
path: '/foo',
baseVersion: 'bafy-base',
genesis: 'bafy-genesis',
generation: 123,
changes: [{op: {case: 'setMetadata', value: {key: 'name', value: 'Foo'}}}],
},
)

expect(createDocumentChange).not.toHaveBeenCalled()
expect(getSigner).toHaveBeenCalledWith('alice')
expect(publishDocument).toHaveBeenCalledWith(
{
account: 'alice',
path: '/foo',
baseVersion: 'bafy-base',
genesis: 'bafy-genesis',
generation: 123,
changes: [{op: {case: 'setMetadata', value: {key: 'name', value: 'Foo'}}}],
},
signer,
)
})
})
4 changes: 4 additions & 0 deletions frontend/apps/desktop/src/utils/publish-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ type PublishDesktopDocumentDeps = {

/** Uses the daemon publish path for updates to existing published documents and new root documents. */
export function shouldUseDaemonCreateDocumentChange(input: PublishDocumentInput): boolean {
// The daemon CreateDocumentChange request cannot carry an explicit generation.
// Keep existing-document publishes on the seed client path so restores stay in the latest generation.
if (input.generation != null && input.baseVersion && input.genesis) return false

// Use daemon for updates to existing documents (has baseVersion and genesis),
// and for new root documents (path is empty) which need genesis creation
// via ensureProfileGenesis — PrepareChange cannot handle this.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import 'fake-indexeddb/auto'
import {signDocumentChange} from '@seed-hypermedia/client'
import {indexedDB} from 'fake-indexeddb'
import type {HMBlockNode, HMDocument, HMSigner, UnpackedHypermediaId} from '@seed-hypermedia/client/hm-types'
import {beforeEach, describe, expect, it, vi} from 'vitest'
import {restoreWebDocumentVersion} from './web-restore-document-version'
import {_resetWebDocDraftDBForTesting, getWebDocDraft, putWebDocDraft} from './web-draft-db'

vi.mock('@seed-hypermedia/client', async () => {
const actual = await vi.importActual<typeof import('@seed-hypermedia/client')>('@seed-hypermedia/client')
return {
...actual,
signDocumentChange: vi.fn(async () => ({
changeCid: {toString: () => 'restored-version'},
publishInput: {blobs: []},
})),
}
})

const DB_NAME = 'web-doc-drafts-01'

function dropDB(): Promise<void> {
return new Promise((resolve) => {
const req = indexedDB.deleteDatabase(DB_NAME)
req.onsuccess = () => resolve()
req.onerror = () => resolve()
})
}

function id(): UnpackedHypermediaId {
return {
uid: 'z1',
path: ['doc'],
id: 'hm://z1/doc',
version: null,
latest: false,
blockRef: null,
blockRange: null,
hostname: null,
scheme: 'hm',
} as UnpackedHypermediaId
}

function node(blockId: string, text: string): HMBlockNode {
return {block: {id: blockId, type: 'Paragraph', text} as any, children: []}
}

function doc(overrides: Partial<HMDocument>): HMDocument {
return {
account: 'z1',
path: '/doc',
version: 'latest-version',
authors: [],
content: [],
metadata: {},
visibility: 'PUBLIC',
createTime: '',
updateTime: '',
genesis: 'genesis',
generationInfo: {generation: 5n},
...overrides,
} as HMDocument
}

describe('restoreWebDocumentVersion', () => {
beforeEach(async () => {
vi.mocked(signDocumentChange).mockClear()
_resetWebDocDraftDBForTesting()
await dropDB()
_resetWebDocDraftDBForTesting()
})

it('publishes selected content on top of latest and removes the current web draft', async () => {
const targetId = id()
await putWebDocDraft({
draftId: 'draft-1',
docId: targetId.id,
signingAccountId: 'z1',
content: [],
metadata: {},
deps: ['latest-version'],
navigation: null,
locationUid: null,
locationPath: null,
editUid: 'z1',
editPath: ['doc'],
cursorPosition: null,
})

const latest = doc({content: [node('a', 'latest'), node('b', 'delete')], metadata: {name: 'Latest'}})
const selected = doc({version: 'old-version', content: [node('a', 'old')], metadata: {name: 'Old'}})
const restored = doc({version: 'restored-version', content: selected.content, metadata: selected.metadata})
const request = vi.fn(async (key: string, input: any) => {
if (key === 'Resource') {
return input.version === 'restored-version'
? {type: 'document', document: restored, id: targetId}
: {type: 'document', document: latest, id: targetId}
}
if (key === 'PrepareDocumentChange') return {unsignedChange: new Uint8Array([1])}
throw new Error(`unexpected request ${key}`)
})
const publish = vi.fn(async () => ({}))

const result = await restoreWebDocumentVersion(
{targetId, selectedVersion: selected, signerAccountUid: 'z1'},
{
client: {request, publish} as any,
getSigner: () => ({getPublicKey: vi.fn(), sign: vi.fn()}) as unknown as HMSigner,
},
)

expect(result.version).toBe('restored-version')
expect(publish).toHaveBeenCalledTimes(1)
const prepareCall = request.mock.calls.find(([key]) => key === 'PrepareDocumentChange')
expect(prepareCall?.[1].baseVersion).toBe('latest-version')
expect(signDocumentChange).toHaveBeenCalledWith(
expect.objectContaining({
genesis: 'genesis',
generation: 5n,
}),
expect.anything(),
)
expect(prepareCall?.[1].changes.map((change: any) => change.op.case)).toEqual([
'setAttribute',
'deleteBlock',
'deleteBlock',
'moveBlock',
'replaceBlock',
])
await expect(getWebDocDraft('draft-1')).resolves.toBeNull()
})
})
Loading
Loading