Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,4 @@ jobs:
run: |
flyctl apps destroy "$APP_NAME" --yes || true
flyctl apps destroy "$APP_NAME-db" --yes || true

24 changes: 18 additions & 6 deletions frontend/src/composables/useAwareness.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
/**
* @file useAwareness composable stub
* @description Provides access to Y.js awareness for presence/collaboration
*/
import { inject } from 'vue'
import type { ShallowRef } from 'vue'

interface AwarenessReturn {
awareness: any
currentUser: { id: number; name: string }
}

export function useAwareness(): AwarenessReturn {
// This is a stub - actual implementation will be in EditorCodeMirror
throw new Error('useAwareness must be mocked in tests or implemented properly')
const awarenessRef = inject<ShallowRef>('awareness')
const userRef = inject<any>('user')

if (!awarenessRef?.value) {
throw new Error('useAwareness: awareness not provided — must be called inside Canvas.vue hierarchy')
}

const user = userRef?.value
if (!user) {
throw new Error('useAwareness: user not provided — must be called inside App.vue hierarchy')
}

return {
awareness: awarenessRef.value,
currentUser: { id: user.id, name: user.name },
}
}
15 changes: 8 additions & 7 deletions frontend/src/composables/useCurrentUser.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
/**
* @file useCurrentUser composable stub
* @description Provides access to current user information
*/

import { inject } from 'vue'
import type { Ref } from 'vue'

interface User {
Expand All @@ -16,6 +12,11 @@ interface CurrentUserReturn {
}

export function useCurrentUser(): CurrentUserReturn {
// This is a stub - actual implementation will use inject('user')
throw new Error('useCurrentUser must be mocked in tests or implemented properly')
const user = inject<Ref<User | null>>('user')

if (!user) {
throw new Error('useCurrentUser: user not provided — must be called inside App.vue hierarchy')
}

return { user }
}
36 changes: 30 additions & 6 deletions frontend/src/composables/useEditor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* @file useEditor composable stub
* @description Provides access to CodeMirror editor instance
*/
import { inject } from 'vue'
import type { ShallowRef } from 'vue'
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import type { Compartment } from '@codemirror/state'

interface EditorReturn {
editor: {
Expand All @@ -11,6 +12,29 @@ interface EditorReturn {
}

export function useEditor(): EditorReturn {
// This is a stub - actual implementation will be in EditorCodeMirror
throw new Error('useEditor must be mocked in tests or implemented properly')
const cmViewRef = inject<ShallowRef>('cmView')
const compartmentRef = inject<ShallowRef<Compartment | null>>('readOnlyCompartment')

if (!cmViewRef?.value) {
throw new Error('useEditor: cmView not provided — must be called inside Canvas.vue hierarchy')
}

if (!compartmentRef?.value) {
throw new Error('useEditor: readOnlyCompartment not provided — editor not initialized')
}

const view = cmViewRef.value
const compartment = compartmentRef.value

return {
editor: {
isReadOnly: () => view.state.facet(EditorState.readOnly),
setReadOnly: (ro: boolean) => {
const exts = ro
? [EditorState.readOnly.of(true), EditorView.editable.of(false)]
: []
view.dispatch({ effects: compartment.reconfigure(exts) })
},
},
}
}
141 changes: 69 additions & 72 deletions frontend/src/composables/useVersionRestore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,16 @@
* @file useVersionRestore composable
* @description Enhanced Pattern B implementation for version restore
*
* Features:
* 1. Concurrent editor detection (confirm dialog)
* 2. Origin tagging for audit trail
* 3. Read-only lock during restore
* 4. Permission: Owner-only (enforced by backend)
* Uses lazy inject pattern: inject() runs at setup time (Vue requirement),
* but .value is only accessed inside restoreVersion() so the composable
* can be called safely during component setup even before the editor is ready.
*/

import { ref } from 'vue'
import { useYText } from './useYText'
import { useAwareness } from './useAwareness'
import { useEditor } from './useEditor'
import { useCurrentUser } from './useCurrentUser'
import { ref, inject } from 'vue'
import type { ShallowRef, Ref } from 'vue'
import { EditorState } from '@codemirror/state'
import type { Compartment } from '@codemirror/state'
import type * as Y from 'yjs'

interface RestoreOrigin {
type: 'restore-version'
Expand All @@ -22,27 +20,23 @@ interface RestoreOrigin {
timestamp: number
}

/**
* Composable for restoring file versions via Y.js
* @param fileId - The file ID to restore versions for
*/
export function useVersionRestore(fileId: number) {
const isRestoring = ref(false)
const { ytext, ydoc } = useYText(fileId)
const { awareness, currentUser } = useAwareness()
const { editor } = useEditor()
const { user } = useCurrentUser()

/**
* Get list of active users (excluding current user)
*/
function getActiveUsers(excludeUserId: number): Array<{ id: number; name: string }> {

// Lazy inject: grab refs at setup, access .value only inside restoreVersion()
const api = inject<any>('api')
const ydocRef = inject<ShallowRef<Y.Doc | null>>('ydoc', null as any)
const awarenessRef = inject<ShallowRef<any>>('awareness', null as any)
const cmViewRef = inject<ShallowRef<any>>('cmView', null as any)
const readOnlyCompartmentRef = inject<ShallowRef<Compartment | null>>('readOnlyCompartment', null as any)
const userRef = inject<Ref<{ id: number; name: string; email: string } | null>>('user', null as any)

function getActiveUsers(awareness: any, excludeUserId: number): Array<{ id: number; name: string }> {
const states = awareness.getStates()
const activeUsers: Array<{ id: number; name: string }> = []

states.forEach((state, _clientId) => {
states.forEach((state: any, _clientId: number) => {
if (state.user && state.user.id !== excludeUserId) {
// Consider user active if they have cursor or are editing
if (state.cursor || state.editing) {
activeUsers.push(state.user)
}
Expand All @@ -52,88 +46,91 @@ export function useVersionRestore(fileId: number) {
return activeUsers
}

/**
* Restore a version by replacing editor content via Y.js
* @param versionId - The version ID to restore
* @returns Promise<boolean> - True if restore succeeded, false if cancelled
*/
async function restoreVersion(versionId: number): Promise<boolean> {
const ydoc = ydocRef?.value
if (!ydoc) {
throw new Error('Editor not available — Y.Doc not initialized')
}
const ytext = ydoc.getText('text')

const awareness = awarenessRef?.value
const view = cmViewRef?.value
const compartment = readOnlyCompartmentRef?.value
const user = userRef?.value
const currentUserId = user?.id ?? 0

let originalReadOnly = false
let editorWasLocked = false

try {
isRestoring.value = true

// 1. Fetch version content
const response = await fetch(
`/api/files/${fileId}/versions/${versionId}/preview`,
{
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
const response = await api.get(`/files/${fileId}/versions/${versionId}/preview`)
const versionContent = response.data.rsm_content

// Check for concurrent editors
if (awareness) {
const activeUsers = getActiveUsers(awareness, currentUserId)
if (activeUsers.length > 0) {
const userNames = activeUsers.map(u => u.name).join(', ')
const confirmed = confirm(
`${activeUsers.length} other user(s) are currently editing (${userNames}). ` +
`Restoring will merge their changes with the restored version. Continue?`
)

if (!confirmed) {
return false
}
}
)

if (!response.ok) {
throw new Error(`Failed to fetch version: ${response.statusText}`)
}

const versionContent = await response.text()

// 2. Check for concurrent editors
const activeUsers = getActiveUsers(currentUser.id)
if (activeUsers.length > 0) {
const userNames = activeUsers.map(u => u.name).join(', ')
const confirmed = confirm(
`${activeUsers.length} other user(s) are currently editing (${userNames}). ` +
`Restoring will merge their changes with the restored version. Continue?`
)

if (!confirmed) {
return false
}
// Lock editor (if view and compartment are available)
if (view && compartment) {
originalReadOnly = view.state.facet(EditorState.readOnly)
const { EditorView: EV } = await import('@codemirror/view')
view.dispatch({
effects: compartment.reconfigure(
[EditorState.readOnly.of(true), EV.editable.of(false)]
)
})
editorWasLocked = true
}

// 3. Lock editor
originalReadOnly = editor.isReadOnly()
editor.setReadOnly(true)
editorWasLocked = true

// 4. Broadcast restore intent
awareness.setLocalStateField('restoring', versionId)
// Broadcast restore intent
if (awareness) {
awareness.setLocalStateField('restoring', versionId)
}

try {
// 5. Perform restore (atomic transaction)
const origin: RestoreOrigin = {
type: 'restore-version',
versionId: versionId,
userId: user.value?.id || currentUser.id,
userId: currentUserId,
timestamp: Date.now()
}

ydoc.transact(() => {
// Delete all current content
ytext.delete(0, ytext.length)

// Insert version content
ytext.insert(0, versionContent)
}, { origin })

// 6. Wait for backend persistence (500ms debounce + buffer)
await new Promise(resolve => setTimeout(resolve, 1000))

return true
} finally {
// 7. Clear restore state
awareness.setLocalStateField('restoring', null)
if (awareness) {
awareness.setLocalStateField('restoring', null)
}
}
} catch (error) {
console.error('Failed to restore version:', error)
throw error
} finally {
// 8. Unlock editor (if it was locked)
if (editorWasLocked) {
editor.setReadOnly(originalReadOnly)
if (editorWasLocked && view && compartment) {
const exts = originalReadOnly
? [EditorState.readOnly.of(true)]
: []
view.dispatch({ effects: compartment.reconfigure(exts) })
}

isRestoring.value = false
Expand Down
20 changes: 12 additions & 8 deletions frontend/src/composables/useYText.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
/**
* @file useYText composable stub
* @description Provides access to Y.Text instance for collaborative editing
*/

import { inject } from 'vue'
import type { ShallowRef } from 'vue'
import type * as Y from 'yjs'

interface YTextReturn {
Expand All @@ -11,7 +8,14 @@ interface YTextReturn {
}

export function useYText(_fileId: number): YTextReturn {
// This is a stub - actual implementation will be in EditorCodeMirror
throw new Error('useYText must be mocked in tests or implemented properly')
}
const ydocRef = inject<ShallowRef<Y.Doc | null>>('ydoc')

if (!ydocRef?.value) {
throw new Error('useYText: ydoc not provided — must be called inside View.vue hierarchy')
}

const ydoc = ydocRef.value
const ytext = ydoc.getText('text')

return { ytext, ydoc }
}
Loading
Loading