Skip to content

Commit 87c9447

Browse files
committed
WIP
1 parent 641758d commit 87c9447

5 files changed

Lines changed: 75 additions & 28 deletions

File tree

collab-server/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const YTEXT_KEY = 'content'
1919
function toDiskPath(documentName: string): string {
2020
const file = path.normalize(documentName)
2121
if (!file.startsWith(projectDir)) {
22-
throw new Error(`Path traversal in document name: ${documentName}`)
22+
throw new Error(`Path traversal in document name: '${documentName}' escapes '${projectDir}'`)
2323
}
2424
return file
2525
}

src/lib/server/collabServer.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export class CollabServerHandle {
4949
await fs.rm(this.socketDir, { recursive: true, force: true })
5050
})
5151

52+
// We identify project files by absolute path
53+
// so this has to match the path used in the openvscode-server bwrap.
54+
const sandboxProjectDir = `/workspace/${this.project.name}/`
5255
const proc = spawn(
5356
'bwrap',
5457
// prettier-ignore
@@ -57,11 +60,11 @@ export class CollabServerHandle {
5760
'--ro-bind', getCollabServerDir(), '/workspace/.collab-server',
5861
'--bind', this.socketDir, '/workspace/.collab-sockets',
5962
// Mount project files as writable for the collaboration server.
60-
'--bind', this.projectDir, '/workspace/project',
63+
'--bind', this.projectDir, sandboxProjectDir,
6164
'/usr/bin/node',
6265
'/workspace/.collab-server/server.ts',
6366
`/workspace/.collab-sockets/${COLLAB_SOCKET_FILENAME}`,
64-
'/workspace/project',
67+
sandboxProjectDir,
6568
],
6669
{ stdio: 'inherit' },
6770
)

vscode-workbench/src/extension.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { HocuspocusProviderWebsocket } from '@hocuspocus/provider'
1+
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider'
22
import fs from 'node:fs/promises'
33
import vs from 'vscode'
44
import WebSocket from 'ws'
55
import { WorkbenchFileSystemProvider } from './fsProvider'
66
import { BWRAP_COLLAB_SOCK_PATH, BWRAP_WORKSPACE_FILE_PATH, WORKBENCH_URI_SCHEME } from './util'
7-
import { registerYjsBindings } from './yTextBinding'
87

98
/** Ensure we are in the expected Lean Workbench environment.
109
* Return `false` if we are not,
@@ -102,11 +101,15 @@ export async function activate(ctx: vs.ExtensionContext) {
102101
const collabSock = await connectToCollabServer(ctx, log)
103102
if (!collabSock) return
104103

105-
const docs = registerYjsBindings(ctx, log, collabSock)
104+
// const docs = registerYjsBindings(ctx, log, collabSock)
106105

107106
const basePath = vs.workspace.workspaceFolders![0].uri.fsPath
107+
const docs2 = new Map<string, HocuspocusProvider>()
108108
ctx.subscriptions.push(
109-
vs.workspace.registerFileSystemProvider(WORKBENCH_URI_SCHEME, new WorkbenchFileSystemProvider(basePath)),
109+
vs.workspace.registerFileSystemProvider(
110+
WORKBENCH_URI_SCHEME,
111+
new WorkbenchFileSystemProvider(basePath, docs2, collabSock, log),
112+
),
110113
)
111114
log.debug('Extension activated')
112115
}

vscode-workbench/src/fsProvider.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider'
12
import path from 'node:path'
23
import vs from 'vscode'
34

@@ -9,10 +10,19 @@ export class WorkbenchFileSystemProvider implements vs.FileSystemProvider {
910
constructor(
1011
/** Base path of the project on disk. */
1112
readonly basePath: string,
13+
/** Absolute path to file ↦ Yjs connection for that file */
14+
// TODO: a wrapper on hocuspocusprovider with a promise for synced
15+
// then in readFile, lazily create one of these,
16+
// wait for initial sync,
17+
// then return the contents.
18+
private readonly docs: Map<string, HocuspocusProvider>,
19+
readonly collabSock: HocuspocusProviderWebsocket,
20+
private readonly log: vs.LogOutputChannel,
1221
) {}
1322

14-
/** Convert a `wrkbnch:` URI to a `file:` URI. */
15-
private toDiskUri(uri: vs.Uri): vs.Uri {
23+
/** Convert a `wrkbnch:` URI to a `file:` URI,
24+
* ensuring that it is accessible. */
25+
private checkedToFileUri(uri: vs.Uri): vs.Uri {
1626
const filePath = path.normalize(uri.fsPath)
1727
if (!`${filePath}/`.startsWith(this.basePath)) {
1828
throw vs.FileSystemError.NoPermissions(uri)
@@ -22,26 +32,52 @@ export class WorkbenchFileSystemProvider implements vs.FileSystemProvider {
2232

2333
// Operations that touch single files go through collab-server
2434

35+
// TODO: move to DocManager
36+
async ensureHpDoc(path: string): Promise<HocuspocusProvider> {
37+
let doc = this.docs.get(path)
38+
// TODO should wait on doc.synced; same deal as `EditorSessionManager`
39+
if (doc) return doc
40+
return new Promise((resolve, reject) => {
41+
doc = new HocuspocusProvider({
42+
websocketProvider: this.collabSock,
43+
name: path,
44+
onSynced: data => {
45+
console.log(`[HocuspocusProvider] '${path}' synced ${String(data.state)}`)
46+
// this.log.trace(`[HocuspocusProvider] '${path}' synced ${String(data.state)}`)
47+
resolve(doc!)
48+
},
49+
})
50+
setTimeout(() => {
51+
reject(new Error('timeout waiting for HocuspocusProvider'))
52+
}, 3_000)
53+
this.docs.set(path, doc)
54+
doc.attach()
55+
})
56+
}
57+
2558
watch(): vs.Disposable {
2659
throw new Error('TODO watch')
2760
}
2861

2962
async stat(uri: vs.Uri): Promise<vs.FileStat> {
3063
// TODO: stat from Yjs
31-
return vs.workspace.fs.stat(this.toDiskUri(uri))
64+
return vs.workspace.fs.stat(this.checkedToFileUri(uri))
3265
}
3366

3467
async readFile(uri: vs.Uri): Promise<Uint8Array> {
35-
// TODO: prefer reads from Yjs
36-
return vs.workspace.fs.readFile(this.toDiskUri(uri))
68+
// TODO: only read from Yjs; ignore fs.
69+
console.log(`reading file ${JSON.stringify(uri)}`)
70+
const doc = await this.ensureHpDoc(this.checkedToFileUri(uri).fsPath)
71+
console.log(`read file ${JSON.stringify(uri)} from Yjs`)
72+
return vs.workspace.fs.readFile(this.checkedToFileUri(uri))
3773
}
3874

3975
async writeFile(
4076
uri: vs.Uri,
4177
content: Uint8Array,
4278
options: { readonly create: boolean; readonly overwrite: boolean },
4379
): Promise<void> {
44-
const existed = await this.stat(this.toDiskUri(uri)).then(
80+
const existed = await this.stat(this.checkedToFileUri(uri)).then(
4581
() => true,
4682
() => false,
4783
)
@@ -54,21 +90,21 @@ export class WorkbenchFileSystemProvider implements vs.FileSystemProvider {
5490
// Filesystem operations go through the filesystem
5591

5692
async readDirectory(uri: vs.Uri): Promise<[string, vs.FileType][]> {
57-
return vs.workspace.fs.readDirectory(this.toDiskUri(uri))
93+
return vs.workspace.fs.readDirectory(this.checkedToFileUri(uri))
5894
}
5995

6096
async createDirectory(uri: vs.Uri): Promise<void> {
61-
await vs.workspace.fs.createDirectory(this.toDiskUri(uri))
97+
await vs.workspace.fs.createDirectory(this.checkedToFileUri(uri))
6298
this.onDidChangeFileEmitter.fire([{ type: vs.FileChangeType.Created, uri }])
6399
}
64100

65101
async delete(uri: vs.Uri, options: { readonly recursive: boolean }): Promise<void> {
66-
await vs.workspace.fs.delete(this.toDiskUri(uri), options)
102+
await vs.workspace.fs.delete(this.checkedToFileUri(uri), options)
67103
this.onDidChangeFileEmitter.fire([{ type: vs.FileChangeType.Deleted, uri }])
68104
}
69105

70106
async rename(oldUri: vs.Uri, newUri: vs.Uri, options: { readonly overwrite: boolean }): Promise<void> {
71-
await vs.workspace.fs.rename(this.toDiskUri(oldUri), this.toDiskUri(newUri), options)
107+
await vs.workspace.fs.rename(this.checkedToFileUri(oldUri), this.checkedToFileUri(newUri), options)
72108
this.onDidChangeFileEmitter.fire([
73109
{ type: vs.FileChangeType.Deleted, uri: oldUri },
74110
{ type: vs.FileChangeType.Created, uri: newUri },

vscode-workbench/src/yTextBinding.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function registerYjsBindings(
1313
const docs = new Map<string, DocEntry>()
1414

1515
const onDidOpenTextDocument = (doc: vs.TextDocument) => {
16+
console.log(`did open ${JSON.stringify(doc.uri)}`)
1617
if (doc.uri.scheme !== WORKBENCH_URI_SCHEME) return
1718
const name = doc.uri.fsPath
1819
if (docs.has(name)) return
@@ -69,6 +70,7 @@ export class YTextBinding implements vs.Disposable {
6970
private ytext: Y.Text
7071
/** Used to prevent `applyEdit` bounceback when applying remote changes;
7172
* when set, local `onDidChangeTextDocument` events are ignored. */
73+
// FIXME: try hard to hack through vscode and tag edit events. Would be much simpler.
7274
private applyingRemote = false
7375
private initialSyncDone = false
7476
/** Used to linearize async operations that might otherwise interleave. */
@@ -78,10 +80,10 @@ export class YTextBinding implements vs.Disposable {
7880

7981
constructor(
8082
readonly doc: vs.TextDocument,
81-
readonly hs: HocuspocusProvider,
83+
readonly hp: HocuspocusProvider,
8284
private readonly log: vs.LogOutputChannel,
8385
) {
84-
this.ytext = hs.document.getText(YTEXT_KEY)
86+
this.ytext = hp.document.getText(YTEXT_KEY)
8587

8688
const observer = (event: Y.YTextEvent, transaction: Y.Transaction) => {
8789
// First check prevents bounceback (https://beta.yjs.dev/docs/api/transactions/#the-origin-concept).
@@ -97,17 +99,17 @@ export class YTextBinding implements vs.Disposable {
9799
},
98100
})
99101

100-
if (hs.synced) {
101-
this.enqueue(() => this.initialSync())
102+
if (hp.synced) {
103+
this.enqueue(() => this.onInitialSync())
102104
} else {
103105
const onSynced = () => {
104-
hs.off('synced', onSynced)
105-
this.enqueue(() => this.initialSync())
106+
hp.off('synced', onSynced)
107+
this.enqueue(() => this.onInitialSync())
106108
}
107-
hs.on('synced', onSynced)
109+
hp.on('synced', onSynced)
108110
this.disposables.push({
109111
dispose: () => {
110-
hs.off('synced', onSynced)
112+
hp.off('synced', onSynced)
111113
},
112114
})
113115
}
@@ -123,10 +125,13 @@ export class YTextBinding implements vs.Disposable {
123125
}
124126

125127
onLocalChange(e: vs.TextDocumentChangeEvent): void {
128+
// BUG 1: local edits made before onInitialSync are lost.
129+
// BUG 2: local edits that arrive while `applyingRemote` is set are lost.
130+
// would also linearizing `onLocalChange` help?
126131
if (this.applyingRemote || !this.initialSyncDone) return
127132
if (e.document !== this.doc) return
128133
if (e.contentChanges.length === 0) return
129-
this.hs.document.transact(() => {
134+
this.hp.document.transact(() => {
130135
// VSCode sorts `contentChanges` in reverse offset order
131136
// so they can be applied sequentially without offset adjustment.
132137
for (const ch of e.contentChanges) {
@@ -136,11 +141,11 @@ export class YTextBinding implements vs.Disposable {
136141
}, this)
137142
}
138143

139-
/** Reconcile the editor with `Y.Text` once the initial sync has completed.
144+
/** Overwrite buffer state with remote `Y.Text` once initial sync has completed.
140145
* Order matters: read `ytext` and set `initialSyncDone = true` synchronously
141146
* so that any remote delta arriving during the subsequent `applyEdit` is queued (not dropped)
142147
* and later applied on top of the new editor content. */
143-
private async initialSync(): Promise<void> {
148+
private async onInitialSync(): Promise<void> {
144149
const ytextStr = this.ytext.toString()
145150
this.initialSyncDone = true
146151
if (ytextStr === this.doc.getText()) return

0 commit comments

Comments
 (0)