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
26 changes: 25 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"publisher": "kermanx",
"name": "p2p-live-share",
"displayName": "P2P Live Share",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"packageManager": "pnpm@10.28.0",
"description": "Live collaboration - a peer-to-peer and open source alternative to Live Share.",
Expand Down Expand Up @@ -131,6 +131,16 @@
"title": "Dummy command",
"category": "P2P Live Share"
},
{
"command": "p2p-live-share.undo",
"title": "Undo",
"category": "P2P Live Share"
},
{
"command": "p2p-live-share.redo",
"title": "Redo",
"category": "P2P Live Share"
},
{
"command": "p2p-live-share.scm.openChange",
"title": "Open Changes",
Expand Down Expand Up @@ -165,6 +175,20 @@
"icon": "$(discard)"
}
],
"keybindings": [
{
"command": "p2p-live-share.undo",
"key": "ctrl+z",
"mac": "cmd+z",
"when": "editorTextFocus && p2p-live-share:inSession"
},
{
"command": "p2p-live-share.redo",
"key": "ctrl+shift+z",
"mac": "cmd+shift+z",
"when": "editorTextFocus && p2p-live-share:inSession"
}
],
"viewsContainers": {
"activitybar": [
{
Expand Down
41 changes: 37 additions & 4 deletions src/fs/common.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FileChangeType, TextDocument, TextDocumentChangeReason, Uri } from 'vscode'
import type * as Y from 'yjs'
import * as Y from 'yjs'
import { useDisposable } from 'reactive-vscode'
import { FileSystemError, Range, window, workspace, WorkspaceEdit } from 'vscode'

Expand All @@ -9,6 +9,34 @@ export interface FileChangeEvent { uri: string, type: FileChangeType }

const editingUris = new Map<string, number>()

// Module-level registry so undo/redo commands can find the right UndoManager
const undoManagers = new Map<string, Y.UndoManager>()

export function registerUndoManager(uri: string, um: Y.UndoManager) {
undoManagers.set(uri, um)
}

export function unregisterUndoManager(uri: string) {
undoManagers.delete(uri)
}

export function findUndoManager(uri: string): Y.UndoManager | undefined {
return undoManagers.get(uri)
}

/** Symbol to mark locally-originated Y.Doc transactions (for Y.UndoManager trackedOrigins) */
const LocalOrigin = Symbol('local')

/** Create a Y.UndoManager that only tracks local changes */
export function createDocUndoManager(uri: string, doc: Y.Doc): Y.UndoManager {
const um = new Y.UndoManager(doc.getText(), {
trackedOrigins: new Set([LocalOrigin]),
captureTimeout: 200,
})
registerUndoManager(uri, um)
return um
}

export function useTextDocumentWatcher(getDoc: (document: TextDocument) => Y.Doc | null | undefined) {
useDisposable(workspace.onDidChangeTextDocument(({ document, contentChanges, reason }) => {
if (contentChanges.length === 0 || editingUris.has(document.uri.toString())) {
Expand All @@ -27,13 +55,18 @@ export function useTextDocumentWatcher(getDoc: (document: TextDocument) => Y.Doc
text.delete(change.rangeOffset, change.rangeLength)
text.insert(change.rangeOffset, change.text)
}
}, { reason })
}, LocalOrigin)
}))
}

export function setupTextDocumentUpdater(uri_: Uri, doc: Y.Doc) {
export function setupTextDocumentUpdater(
uri_: Uri,
doc: Y.Doc,
um?: Y.UndoManager,
) {
doc.getText().observe((event) => {
if (event.transaction.local)
// Skip local changes UNLESS they came from UndoManager (needs to sync to editor)
if (event.transaction.local && event.transaction.origin !== um)
return
applyTextDocumentDelta(uri_, event.delta, event.transaction.origin?.reason)
})
Expand Down
8 changes: 6 additions & 2 deletions src/fs/guest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { FileChangeEvent } from './common'
import { computed, defineConfig, useDisposable } from 'reactive-vscode'
import { FileType, Uri, workspace } from 'vscode'
import * as Y from 'yjs'
import { forceUpdateContent, handleFsError, setupTextDocumentUpdater, useTextDocumentWatcher } from './common'
import { createDocUndoManager, forceUpdateContent, handleFsError, setupTextDocumentUpdater, unregisterUndoManager, useTextDocumentWatcher } from './common'
import { CustomUriScheme, useFsProvider } from './provider'

const filesConfig = defineConfig<any>('files')
Expand All @@ -18,6 +18,7 @@ export function useGuestFs(connection: Connection, rpc: BirpcReturn<HostFunction
doc: Y.Doc
mtime: number
ctime?: number
undoManager: Y.UndoManager
}>()
const [send, recv] = connection.makeAction<Uint8Array, [string, TextDocumentChangeReason?]>('texts')

Expand All @@ -30,19 +31,21 @@ export function useGuestFs(connection: Connection, rpc: BirpcReturn<HostFunction

async function trackContent(uri: string) {
const doc = new Y.Doc()
const undoManager = createDocUndoManager(uri, doc)
const init = await rpc.trackContent({ guestId: connection.selfId, uri })
Y.applyUpdateV2(doc, init)
files.set(uri, {
doc,
mtime: Date.now(),
undoManager,
})

doc.on('updateV2', async (update: Uint8Array, origin: any) => {
if (origin?.peerId)
return
await send(update, hostId, [uri, origin?.reason])
})
setupTextDocumentUpdater(Uri.parse(uri), doc)
setupTextDocumentUpdater(Uri.parse(uri), doc, undoManager)
}

useTextDocumentWatcher((document) => {
Expand All @@ -63,6 +66,7 @@ export function useGuestFs(connection: Connection, rpc: BirpcReturn<HostFunction
}))
useDisposable(workspace.onDidCloseTextDocument(({ uri }) => {
if (uri.scheme === CustomUriScheme) {
unregisterUndoManager(uri.toString())
files.delete(uri.toString())
rpc.untrackContent({ guestId: connection.selfId, uri: uri.toString() })
}
Expand Down
9 changes: 6 additions & 3 deletions src/fs/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import picomatch from 'picomatch'
import { useDisposable } from 'reactive-vscode'
import { Disposable, FileChangeType, RelativePattern, Uri, workspace } from 'vscode'
import * as Y from 'yjs'
import { forceUpdateContent, fsErrorWrapper, setupTextDocumentUpdater, useTextDocumentWatcher } from './common'
import { createDocUndoManager, forceUpdateContent, fsErrorWrapper, setupTextDocumentUpdater, unregisterUndoManager, useTextDocumentWatcher } from './common'

export function useHostFs(connection: Connection) {
const { toHostUri, toTrackUri } = connection

const files = new Map<string, {
doc: Y.Doc
trackers: Set<string>
undoManager: Y.UndoManager
}>()

const [send, recv] = connection.makeAction<Uint8Array, [string, TextDocumentChangeReason?]>('texts')
Expand All @@ -35,7 +36,8 @@ export function useHostFs(connection: Connection) {
else {
const doc = new Y.Doc()
const trackers = new Set<string>([guestId])
files.set(uri, { doc, trackers })
const undoManager = createDocUndoManager(uri, doc)
files.set(uri, { doc, trackers, undoManager })

doc.on('updateV2', async (update: Uint8Array, origin: any) => {
if (origin?.peerId)
Expand All @@ -44,7 +46,7 @@ export function useHostFs(connection: Connection) {
})

const uri_ = toHostUri(Uri.parse(uri))
setupTextDocumentUpdater(uri_, doc)
setupTextDocumentUpdater(uri_, doc, undoManager)

const newText = content ?? new TextDecoder().decode(await workspace.fs.readFile(uri_))
doc.getText().insert(0, newText)
Expand All @@ -58,6 +60,7 @@ export function useHostFs(connection: Connection) {
file.trackers.delete(guestId)
if (file.trackers.size === 0) {
files.delete(uri)
unregisterUndoManager(uri)
file.doc.destroy()
}
}
Expand Down
133 changes: 133 additions & 0 deletions src/fs/undo-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test
import { describe, it } from 'node:test'
import * as Y from 'yjs'

/**
* 独立测试 Y.UndoManager 在协同编辑场景下的行为。
* 不能直接 import common.ts(依赖 vscode 模块),
* 所以这里用和 common.ts 相同的逻辑独立构造测试。
*/
const LocalOrigin = Symbol('local')

function createUndoManager(doc: Y.Doc): Y.UndoManager {
return new Y.UndoManager(doc.getText(), {
trackedOrigins: new Set([LocalOrigin]),
captureTimeout: 200,
})
}

describe('Y.UndoManager collaborative undo behavior', () => {
it('tracks local changes, ignores remote changes', () => {
const doc = new Y.Doc()
const um = createUndoManager(doc)

// 模拟本地编辑
doc.transact(() => {
doc.getText().insert(0, 'local')
}, LocalOrigin)
assert.equal(um.undoStack.length, 1, 'should track local change')

// 模拟远程变更 (origin !== LocalOrigin)
doc.transact(() => {
doc.getText().insert(5, '-remote')
}, { peerId: 'peer' })
assert.equal(um.undoStack.length, 1, 'should NOT track remote change as new item')

assert.equal(doc.getText().toString(), 'local-remote')

// Undo: 只撤销本地变更
um.undo()
assert.equal(doc.getText().toString(), '-remote',
`undo should leave only remote text, got "${doc.getText().toString()}"`)

doc.destroy()
})

it('correctly undoes with concurrent interleaved edits', () => {
const doc = new Y.Doc()
const localUm = createUndoManager(doc)

// 本地用户插入 "hello" — 5 个 CRDT items
doc.transact(() => {
doc.getText().insert(0, 'hello')
}, LocalOrigin)

assert.equal(doc.getText().toString(), 'hello')

// 模拟远程 peer 在 "hel" 和 "lo" 之间插入 "X"
// 用 applyUpdateV2 模拟远程更新 — origin 不是 LocalOrigin
const remoteDoc = new Y.Doc()
Y.applyUpdateV2(remoteDoc, Y.encodeStateAsUpdateV2(doc))
remoteDoc.getText().insert(3, 'X')
const remoteUpdate = Y.encodeStateAsUpdateV2(remoteDoc)

// 应用远程更新
Y.applyUpdateV2(doc, remoteUpdate, { peerId: 'remote' })
assert.equal(doc.getText().toString(), 'helXlo',
`concurrent edit should produce "helXlo", got "${doc.getText().toString()}"`)

// 本地 undo — UndoManager 知道 "hello" 对应的 CRDT items
localUm.undo()
assert.equal(doc.getText().toString(), 'X',
`undo should leave only "X", got "${doc.getText().toString()}"`)

doc.destroy()
remoteDoc.destroy()
})

it('undo then redo restores original text', () => {
const doc = new Y.Doc()
const um = createUndoManager(doc)

doc.transact(() => {
doc.getText().insert(0, 'test')
}, LocalOrigin)

assert.equal(doc.getText().toString(), 'test')

um.undo()
assert.equal(doc.getText().toString(), '')

um.redo()
assert.equal(doc.getText().toString(), 'test')

assert.equal(um.undoStack.length, 1)
assert.equal(um.redoStack.length, 0)

doc.destroy()
})

it('only undoes local transactions, not remote ones mixed in between', () => {
const doc = new Y.Doc()
const um = createUndoManager(doc)

// 本地插入 "A"
doc.transact(() => doc.getText().insert(0, 'A'), LocalOrigin)
// 远程插入 "B" 在 "A" 之后
doc.transact(() => doc.getText().insert(1, 'B'), { peerId: 'peer' })
// 本地插入 "C" 在 "B" 之后
doc.transact(() => doc.getText().insert(2, 'C'), LocalOrigin)

assert.equal(doc.getText().toString(), 'ABC')
// captureTimeout=200ms 可能把两个本地事务合并为一个 undo step
// 重要的是:撤销后只删除本地插入的字符,不删远程的 "B"
assert.ok(um.undoStack.length >= 1, 'should have at least 1 local undo item')

// 撤销所有本地事务
while (um.undoStack.length > 0)
um.undo()

assert.equal(doc.getText().toString(), 'B',
`after undoing all local changes, only remote "B" should remain, got "${doc.getText().toString()}"`)

// Redo 恢复本地变更
while (um.redoStack.length > 0)
um.redo()

assert.equal(doc.getText().toString(), 'ABC',
`after redo all should be "ABC", got "${doc.getText().toString()}"`)

doc.destroy()
})
})
19 changes: 19 additions & 0 deletions src/session/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { computed, defineService, onScopeDispose, shallowRef, useCommand, useVscodeContext, watch } from 'reactive-vscode'
import { commands, env, Uri, window, workspace } from 'vscode'
import { version } from '../../package.json'
import { findUndoManager } from '../fs/common'
import { CustomUriScheme } from '../fs/provider'
import { copyShareLink, inquireHostConfig, makeTrackUri, parseTrackUri, validateShareLink } from '../sync/share'
import { useUsers } from '../ui/users'
Expand Down Expand Up @@ -205,11 +206,29 @@ export const useActiveSession = defineService(() => {
useVscodeContext('p2p-live-share:isHost', computed(() => session.value?.role === 'host'))
useVscodeContext('p2p-live-share:isGuest', computed(() => session.value?.role === 'guest'))

function activeTrackUri(): string | undefined {
const editor = window.activeTextEditor
if (!editor || !session.value) return
return session.value.role === 'host'
? toTrackUri(editor.document.uri)?.toString()
: editor.document.uri.toString()
}

useCommand('p2p-live-share.host', host)
useCommand('p2p-live-share.join', () => join(false))
useCommand('p2p-live-share.joinNewWindow', () => join(true))
useCommand('p2p-live-share.leave', leave)
useCommand('p2p-live-share.stop', leave)
useCommand('p2p-live-share.undo', () => {
const uri = activeTrackUri()
const um = uri && findUndoManager(uri)
um ? um.undo() : commands.executeCommand('undo')
})
useCommand('p2p-live-share.redo', () => {
const uri = activeTrackUri()
const um = uri && findUndoManager(uri)
um ? um.redo() : commands.executeCommand('redo')
})
useCommand('p2p-live-share.copyInviteLink', () => {
if (session.value?.connection) {
copyShareLink(session.value?.connection.config)
Expand Down
Loading
Loading