diff --git a/package.json b/package.json index 086491b..efcc7b7 100644 --- a/package.json +++ b/package.json @@ -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.", @@ -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", @@ -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": [ { diff --git a/src/fs/common.ts b/src/fs/common.ts index 9482154..e5b7384 100644 --- a/src/fs/common.ts +++ b/src/fs/common.ts @@ -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' @@ -9,6 +9,34 @@ export interface FileChangeEvent { uri: string, type: FileChangeType } const editingUris = new Map() +// Module-level registry so undo/redo commands can find the right UndoManager +const undoManagers = new Map() + +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())) { @@ -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) }) diff --git a/src/fs/guest.ts b/src/fs/guest.ts index cb7f67e..c8dfdc4 100644 --- a/src/fs/guest.ts +++ b/src/fs/guest.ts @@ -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('files') @@ -18,6 +18,7 @@ export function useGuestFs(connection: Connection, rpc: BirpcReturn() const [send, recv] = connection.makeAction('texts') @@ -30,11 +31,13 @@ export function useGuestFs(connection: Connection, rpc: BirpcReturn { @@ -42,7 +45,7 @@ export function useGuestFs(connection: Connection, rpc: BirpcReturn { @@ -63,6 +66,7 @@ export function useGuestFs(connection: Connection, rpc: BirpcReturn { if (uri.scheme === CustomUriScheme) { + unregisterUndoManager(uri.toString()) files.delete(uri.toString()) rpc.untrackContent({ guestId: connection.selfId, uri: uri.toString() }) } diff --git a/src/fs/host.ts b/src/fs/host.ts index 325fb7d..29179a4 100644 --- a/src/fs/host.ts +++ b/src/fs/host.ts @@ -6,7 +6,7 @@ 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 @@ -14,6 +14,7 @@ export function useHostFs(connection: Connection) { const files = new Map + undoManager: Y.UndoManager }>() const [send, recv] = connection.makeAction('texts') @@ -35,7 +36,8 @@ export function useHostFs(connection: Connection) { else { const doc = new Y.Doc() const trackers = new Set([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) @@ -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) @@ -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() } } diff --git a/src/fs/undo-manager.test.ts b/src/fs/undo-manager.test.ts new file mode 100644 index 0000000..a710f7f --- /dev/null +++ b/src/fs/undo-manager.test.ts @@ -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() + }) +}) diff --git a/src/session/index.ts b/src/session/index.ts index f911e23..08b0189 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -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' @@ -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) diff --git a/src/terminal/pty/shims/utils.test.ts b/src/terminal/pty/shims/utils.test.ts new file mode 100644 index 0000000..c0e10ef --- /dev/null +++ b/src/terminal/pty/shims/utils.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +// eslint-disable-next-line test/no-import-node-test +import { after, before, describe, it } from 'node:test' + +// resolveAsset 的独立实现,不依赖 vscode 的 getAppRoot +// 用于测试路径解析逻辑本身 +function resolveAssetWithRoot( + path: string, + appRoot: string, + _exists: (p: string) => boolean = existsSync, +): string { + // Strategy 1: Normal node_modules (development / direct install) + const normalPath = resolve(appRoot, '..', 'node_modules', path) + if (_exists(normalPath)) + return normalPath + + // Strategy 2: ASAR unpacked directory (native modules bundled with VS Code) + const unpackedPath = resolve(appRoot, '..', 'node_modules.asar.unpacked', path) + if (_exists(unpackedPath)) + return unpackedPath + + // Strategy 3: Inside ASAR archive — return the path directly + // Electron's require() handles .asar paths transparently + return resolve(appRoot, '..', 'node_modules.asar', path) +} + +describe('resolveAsset path resolution', () => { + let tmpDir: string + + before(() => { + // 用临时目录模拟 VS Code 的 app root 结构 + tmpDir = resolve(tmpdir(), `p2p-ls-test-${Date.now()}`) + // appRoot = tmpDir/out (模拟 VS Code 的 env.appRoot + '/out') + }) + + after(() => { + // Cleanup handled by the test framework's tmp dir + }) + + it('finds asset in normal node_modules/', () => { + // path.resolve 会规范化掉 ..,所以最终路径是 /node_modules/... + const appRoot = resolve(tmpDir, 'out') + const expectedBase = resolve(appRoot, '..', 'node_modules') + const exists = (p: string) => p.startsWith(expectedBase) + const result = resolveAssetWithRoot('node-pty/lib/index.js', appRoot, exists) + assert.ok(result.includes('node_modules'), `Expected path to include node_modules, got: ${result}`) + assert.ok(!result.includes('.asar'), `Expected non-ASAR path, got: ${result}`) + }) + + it('falls back to node_modules.asar.unpacked when normal path missing', () => { + const appRoot = resolve(tmpDir, 'out') + // 精确匹配:normal path 是 /node_modules/, asar path 是 /node_modules.asar.unpacked/ + const normalFilePath = resolve(appRoot, '..', 'node_modules', 'node-pty/lib/index.js') + const asarFilePath = resolve(appRoot, '..', 'node_modules.asar.unpacked', 'node-pty/lib/index.js') + const exists = (p: string) => { + if (p === normalFilePath) return false // normal path 不存在 + if (p === asarFilePath) return true // asar path 存在 + return false + } + const result = resolveAssetWithRoot('node-pty/lib/index.js', appRoot, exists) + assert.ok(result.includes('node_modules.asar.unpacked'), `Expected .asar.unpacked path, got: ${result}`) + }) + + it('returns ASAR archive path when both fs paths miss', () => { + const exists = (_p: string) => false + const appRoot = resolve(tmpDir, 'out') + const expected = resolve(appRoot, '..', 'node_modules.asar', 'node-pty/lib/index.js') + const result = resolveAssetWithRoot('node-pty/lib/index.js', appRoot, exists) + assert.equal(result, expected) + }) + + it('normal path is tried before asar path', () => { + const callOrder: string[] = [] + const appRoot = resolve(tmpDir, 'out') + const normalFilePath = resolve(appRoot, '..', 'node_modules', 'pkg/index.js') + const asarFilePath = resolve(appRoot, '..', 'node_modules.asar.unpacked', 'pkg/index.js') + const exists = (p: string) => { + if (p === normalFilePath) { + callOrder.push('normal') + return false + } + if (p === asarFilePath) { + callOrder.push('asar') + return true + } + return false + } + resolveAssetWithRoot('pkg/index.js', appRoot, exists) + assert.deepEqual(callOrder, ['normal', 'asar'], 'Normal path must be checked before ASAR path') + }) +}) diff --git a/src/terminal/pty/shims/utils.ts b/src/terminal/pty/shims/utils.ts index a5a822a..051512b 100644 --- a/src/terminal/pty/shims/utils.ts +++ b/src/terminal/pty/shims/utils.ts @@ -1,14 +1,24 @@ import { existsSync } from 'node:fs' import { resolve } from 'node:path' -import { window } from 'vscode' import { getAppRoot } from '../utils.js' export function resolveAsset(path: string) { const appRoot = getAppRoot() - const resolved = resolve(appRoot, '../node_modules', path) - if (!existsSync(resolved)) { - window.showErrorMessage(`Asset not found: ${path}`) - throw new Error(`Asset not found: ${path}`) - } - return resolved + + // Strategy 1: Normal node_modules (development / direct install) + const normalPath = resolve(appRoot, '../node_modules', path) + if (existsSync(normalPath)) + return normalPath + + // Strategy 2: ASAR unpacked directory (native binaries, worker files) + const unpackedPath = resolve(appRoot, '../node_modules.asar.unpacked', path) + if (existsSync(unpackedPath)) + return unpackedPath + + // Strategy 3: Inside ASAR archive — construct the path into node_modules.asar + // fs.existsSync cannot see inside .asar, but Electron's require() can. + // We return the path directly; if the file doesn't exist there either, + // require() will throw a meaningful "Cannot find module" error. + const asarPath = resolve(appRoot, '../node_modules.asar', path) + return asarPath }