Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c99f3e5fe8 | ||
|
|
a9021312b0 | ||
|
|
2726a66113 |
No files matched your search
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.10.0 — 2026-11-06
|
||||||
|
|
||||||
|
- **Archify Sync (optional).** Several people edit one model at once through a server you run yourself ([archify-vscode-ext-sync](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync)); the files stay ordinary files on each disk. Commands: *Sign in to Sync Server…*, *Share Model…*, *Copy Invite Link*, *Join Shared Model…*, *Publish Version…*, *Versions…*, *Stop Syncing Model…*. Sign-in through the browser (PKCE), sessions in VS Code's secret storage, an *Archify Sync* account provider. Edits from editors, pages, Git and agents go out at once; others' edits arrive in open editors with undo intact. Everything is kept on the machine first, so work goes on offline and merges later. Teammates' cursors show as named carets in the Source view and in VS Code's text editors, and stay on their word while others type. The status bar shows who is here; the model page gets a **Shared** section with people, the file each has open, and the latest versions with **Compare** and **Restore**.
|
||||||
|
- Without a server nothing changes: the sync code is a separate bundle that loads only when a model in the workspace is shared, after a sign-in, or when a sync command or invite link is used.
|
||||||
|
|
||||||
|
## 0.9.3 — 2026-11-04
|
||||||
|
|
||||||
|
- Fixed: on a `main.archify` page in Source, **Graph** did nothing, and **Model** then showed the Graph page instead of the model. Graph now leaves Source and opens at once, and **Model** on the Graph page comes back to the model, not to Source.
|
||||||
|
|
||||||
## 0.9.2 — 2026-10-27
|
## 0.9.2 — 2026-10-27
|
||||||
|
|
||||||
- **Source view.** Every Archify page can show its file's YAML in place: **Diagram | Graph | Source** on diagrams (the old **Diagram | Graph** switch, now on every diagram; Graph only inside a model), **Model | Graph | Source** on `main.archify`, **View | Source** on registry and decision pages, and Source on the Graph page too. The YAML opens in an editor inside the page, under its toolbar and heading, in the page's own colours: line numbers, folding, brackets, multiple cursors, undo and redo, ⌘S/Ctrl+S saves. Problems are underlined as you type, listed under the editor (click the count to go to the next one) and counted on the **Source** segment, also while the picture shows. Completion and hover come from the schema of the file, with nothing else installed. Edits go to the VS Code document, so the tab's dirty dot, save, Git and the text editor stay in step. **Diagram** (or **Model**, **View**) brings the picture back as it was left; anything rendered meanwhile shows then.
|
- **Source view.** Every Archify page can show its file's YAML in place: **Diagram | Graph | Source** on diagrams (the old **Diagram | Graph** switch, now on every diagram; Graph only inside a model), **Model | Graph | Source** on `main.archify`, **View | Source** on registry and decision pages, and Source on the Graph page too. The YAML opens in an editor inside the page, under its toolbar and heading, in the page's own colours: line numbers, folding, brackets, multiple cursors, undo and redo, ⌘S/Ctrl+S saves. Problems are underlined as you type, listed under the editor (click the count to go to the next one) and counted on the **Source** segment, also while the picture shows. Completion and hover come from the schema of the file, with nothing else installed. Edits go to the VS Code document, so the tab's dirty dot, save, Git and the text editor stay in step. **Diagram** (or **Model**, **View**) brings the picture back as it was left; anything rendered meanwhile shows then.
|
||||||
|
|||||||
@@ -134,6 +134,22 @@ online-shop/
|
|||||||
|
|
||||||
Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop).
|
Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop).
|
||||||
|
|
||||||
|
## Working together (Archify Sync)
|
||||||
|
|
||||||
|
Optional. Without a sync server the extension works exactly as described above and loads none of this.
|
||||||
|
|
||||||
|
[Archify Sync](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync) is a small server you run yourself (one container and PostgreSQL; a Helm chart is included). With it, a model is edited by several people at once, like a shared document, and its files stay ordinary files on each person's disk, so Git, agents and every page of this extension keep working.
|
||||||
|
|
||||||
|
- **Sign in.** *Archify | Sign in to Sync Server…* asks for the server's address and signs in through the browser; the session is kept in VS Code's secret storage and shows under **Accounts** as *Archify Sync*. A new server is set up first on its `/setup` page with the code from its log.
|
||||||
|
- **Share.** *Archify | Share Model…* (also on `main.archify` in the Explorer) puts the model on the server and writes a small `.archify-sync` file next to `main.archify`: the server and the model's id, no secrets. Commit it, and everyone who clones the repository and is a member opens the shared model.
|
||||||
|
- **Invite and join.** *Archify | Copy Invite Link* copies a link for the model; send it any way you like. Opening it (or *Archify | Join Shared Model…*) signs the person in, or up, and puts the files in an empty folder of their choice.
|
||||||
|
- **Live.** Edits in any editor, page, Source view, by Git or by an agent reach the others within a moment. Their edits arrive in your open editors with undo intact and are saved to disk; your own unsaved work stays unsaved. The status bar shows **Archify live · 3 here**; the model page gets a **Shared** section with who is here and in which file, and each diagram card says who has it open. In the Source view and in text editors, teammates' cursors show as coloured carets with their names.
|
||||||
|
- **Offline.** Every edit is kept on your machine first. Without a connection you keep working; edits on both sides merge when it is back, also after a restart, and also edits made while VS Code was closed.
|
||||||
|
- **Versions.** The server takes a snapshot after a pause in editing (0.1, 0.2, …). *Archify | Publish Version…* names the current state as a major version (1.0 "Q4 baseline"). The model page lists the latest versions with **Compare** (a diff per changed file) and **Restore** (brings the files back for everyone, as a new version; nothing is lost); *Archify | Versions…* lists them all.
|
||||||
|
- **Stop.** *Archify | Stop Syncing Model…* removes `.archify-sync` here and leaves the files as they are.
|
||||||
|
|
||||||
|
Only model files (`.archify`, `.archify-registry`, `.archify-decision`) are shared, and only below `main.archify`.
|
||||||
|
|
||||||
## Source evidence
|
## Source evidence
|
||||||
|
|
||||||
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
|
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
|
||||||
@@ -156,7 +172,7 @@ When verification fails, the default (`archify.sourceEvidence: "fallback"`) stil
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts and the extension's bundled 3D viewer run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code.
|
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts and the extension's bundled 3D viewer run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code. The extension itself talks to a network only for Archify Sync, and only to a server you sign in to or a model's `.archify-sync` file names, over HTTPS (plain HTTP only to this machine).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ if (tests) {
|
|||||||
outfile: 'dist/extension.js',
|
outfile: 'dist/extension.js',
|
||||||
external: ['vscode'],
|
external: ['vscode'],
|
||||||
}),
|
}),
|
||||||
|
// Archify Sync, loaded only when a model is shared (src/sync/loader.ts).
|
||||||
|
// ws's native speed-ups are optional and not shipped.
|
||||||
|
esbuild.context({
|
||||||
|
...common,
|
||||||
|
entryPoints: ['src/sync/index.ts'],
|
||||||
|
outfile: 'dist/sync.js',
|
||||||
|
external: ['vscode', 'bufferutil', 'utf-8-validate'],
|
||||||
|
}),
|
||||||
// Runs inside the Archify page in the webview; three.js is bundled in.
|
// Runs inside the Archify page in the webview; three.js is bundled in.
|
||||||
esbuild.context({
|
esbuild.context({
|
||||||
...common,
|
...common,
|
||||||
|
|||||||
Generated
+107
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"version": "0.9.2",
|
"version": "0.10.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"version": "0.9.2",
|
"version": "0.10.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.4",
|
"@codemirror/autocomplete": "^6.20.4",
|
||||||
@@ -22,8 +22,12 @@
|
|||||||
"codemirror-json-schema": "^0.8.1",
|
"codemirror-json-schema": "^0.8.1",
|
||||||
"cytoscape": "^3.34.4",
|
"cytoscape": "^3.34.4",
|
||||||
"cytoscape-fcose": "^2.2.0",
|
"cytoscape-fcose": "^2.2.0",
|
||||||
|
"lib0": "^0.2.119",
|
||||||
"three": "^0.186.1",
|
"three": "^0.186.1",
|
||||||
"yaml": "^2.9.1"
|
"ws": "^8.22.1",
|
||||||
|
"y-protocols": "^1.0.7",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
|
"yjs": "^13.6.33"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cytoscape": "^3.21.9",
|
"@types/cytoscape": "^3.21.9",
|
||||||
@@ -31,6 +35,7 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/three": "^0.186.0",
|
"@types/three": "^0.186.0",
|
||||||
"@types/vscode": "~1.100.0",
|
"@types/vscode": "~1.100.0",
|
||||||
|
"@types/ws": "^8.18.2",
|
||||||
"@vscode/test-electron": "^3.1.0",
|
"@vscode/test-electron": "^3.1.0",
|
||||||
"@vscode/vsce": "^3.0.0",
|
"@vscode/vsce": "^3.0.0",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
@@ -1392,6 +1397,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
|
||||||
|
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typespec/ts-http-runtime": {
|
"node_modules/@typespec/ts-http-runtime": {
|
||||||
"version": "0.3.9",
|
"version": "0.3.9",
|
||||||
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
|
||||||
@@ -3308,6 +3323,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/isomorphic.js": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/istextorbinary": {
|
"node_modules/istextorbinary": {
|
||||||
"version": "9.5.0",
|
"version": "9.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz",
|
||||||
@@ -3547,6 +3572,27 @@
|
|||||||
"@lezer/lr": "^1.0.0"
|
"@lezer/lr": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lib0": {
|
||||||
|
"version": "0.2.119",
|
||||||
|
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz",
|
||||||
|
"integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"isomorphic.js": "^0.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||||
|
"0gentesthtml": "bin/gentesthtml.js",
|
||||||
|
"0serve": "bin/0serve.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lie": {
|
"node_modules/lie": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
@@ -5707,6 +5753,27 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
|
||||||
|
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wsl-utils": {
|
"node_modules/wsl-utils": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||||
@@ -5747,6 +5814,26 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/y-protocols": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.85"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"yjs": "^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
@@ -5792,6 +5879,23 @@
|
|||||||
"buffer-crc32": "~0.2.3"
|
"buffer-crc32": "~0.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yjs": {
|
||||||
|
"version": "13.6.33",
|
||||||
|
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz",
|
||||||
|
"integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.99"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zwitch": {
|
"node_modules/zwitch": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||||
|
|||||||
+85
-3
@@ -2,7 +2,7 @@
|
|||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"displayName": "Archify Diagram Viewer",
|
"displayName": "Archify Diagram Viewer",
|
||||||
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.",
|
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.",
|
||||||
"version": "0.9.2",
|
"version": "0.10.0",
|
||||||
"publisher": "root-at-skic",
|
"publisher": "root-at-skic",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"sponsor": {
|
"sponsor": {
|
||||||
@@ -45,7 +45,8 @@
|
|||||||
"workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}",
|
"workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}",
|
||||||
"onWebviewPanel:archify.preview",
|
"onWebviewPanel:archify.preview",
|
||||||
"workspaceContains:**/*.archify-registry",
|
"workspaceContains:**/*.archify-registry",
|
||||||
"workspaceContains:**/*.archify-decision"
|
"workspaceContains:**/*.archify-decision",
|
||||||
|
"onUri"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"commands": [
|
"commands": [
|
||||||
@@ -118,6 +119,51 @@
|
|||||||
"command": "archify.openModelGraph",
|
"command": "archify.openModelGraph",
|
||||||
"title": "Archify | Open Model Graph",
|
"title": "Archify | Open Model Graph",
|
||||||
"icon": "$(type-hierarchy)"
|
"icon": "$(type-hierarchy)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.signIn",
|
||||||
|
"title": "Archify | Sign in to Sync Server…",
|
||||||
|
"icon": "$(account)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.signOut",
|
||||||
|
"title": "Archify | Sign out of Sync Server",
|
||||||
|
"icon": "$(sign-out)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.share",
|
||||||
|
"title": "Archify | Share Model…",
|
||||||
|
"icon": "$(live-share)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.invite",
|
||||||
|
"title": "Archify | Copy Invite Link",
|
||||||
|
"icon": "$(person-add)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.join",
|
||||||
|
"title": "Archify | Join Shared Model…",
|
||||||
|
"icon": "$(link)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.publish",
|
||||||
|
"title": "Archify | Publish Version…",
|
||||||
|
"icon": "$(tag)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.versions",
|
||||||
|
"title": "Archify | Versions…",
|
||||||
|
"icon": "$(history)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.stop",
|
||||||
|
"title": "Archify | Stop Syncing Model…",
|
||||||
|
"icon": "$(debug-disconnect)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.menu",
|
||||||
|
"title": "Archify | Sync…",
|
||||||
|
"icon": "$(broadcast)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
@@ -161,6 +207,26 @@
|
|||||||
{
|
{
|
||||||
"command": "archify.openModelGraph",
|
"command": "archify.openModelGraph",
|
||||||
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
|
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.invite",
|
||||||
|
"when": "archify.sync.active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.publish",
|
||||||
|
"when": "archify.sync.active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.versions",
|
||||||
|
"when": "archify.sync.active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.stop",
|
||||||
|
"when": "archify.sync.active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.menu",
|
||||||
|
"when": "archify.sync.active"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor/title": [
|
"editor/title": [
|
||||||
@@ -250,6 +316,11 @@
|
|||||||
"command": "archify.openModelGraph",
|
"command": "archify.openModelGraph",
|
||||||
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
|
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
|
||||||
"group": "zzz_archify@9"
|
"group": "zzz_archify@9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.sync.share",
|
||||||
|
"when": "resourceFilename == main.archify",
|
||||||
|
"group": "archify@9"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor/title/context": [
|
"editor/title/context": [
|
||||||
@@ -464,6 +535,12 @@
|
|||||||
"fileMatch": "*.archify-registry",
|
"fileMatch": "*.archify-registry",
|
||||||
"url": "./schemas/archify-registry.schema.json"
|
"url": "./schemas/archify-registry.schema.json"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"authentication": [
|
||||||
|
{
|
||||||
|
"id": "archify-sync",
|
||||||
|
"label": "Archify Sync"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -482,6 +559,7 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/three": "^0.186.0",
|
"@types/three": "^0.186.0",
|
||||||
"@types/vscode": "~1.100.0",
|
"@types/vscode": "~1.100.0",
|
||||||
|
"@types/ws": "^8.18.2",
|
||||||
"@vscode/test-electron": "^3.1.0",
|
"@vscode/test-electron": "^3.1.0",
|
||||||
"@vscode/vsce": "^3.0.0",
|
"@vscode/vsce": "^3.0.0",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
@@ -506,7 +584,11 @@
|
|||||||
"codemirror-json-schema": "^0.8.1",
|
"codemirror-json-schema": "^0.8.1",
|
||||||
"cytoscape": "^3.34.4",
|
"cytoscape": "^3.34.4",
|
||||||
"cytoscape-fcose": "^2.2.0",
|
"cytoscape-fcose": "^2.2.0",
|
||||||
|
"lib0": "^0.2.119",
|
||||||
"three": "^0.186.1",
|
"three": "^0.186.1",
|
||||||
"yaml": "^2.9.1"
|
"ws": "^8.22.1",
|
||||||
|
"y-protocols": "^1.0.7",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
|
"yjs": "^13.6.33"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import { DiagramViewManager } from './views/diagramView';
|
|||||||
import { RegistryViewProvider } from './views/registryView';
|
import { RegistryViewProvider } from './views/registryView';
|
||||||
import { DecisionViewProvider } from './views/decisionView';
|
import { DecisionViewProvider } from './views/decisionView';
|
||||||
import { DECISION_SELECTOR, DecisionLanguage } from './language/decisionLanguage';
|
import { DECISION_SELECTOR, DecisionLanguage } from './language/decisionLanguage';
|
||||||
|
import { registerSync } from './sync/loader';
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): void {
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
const models = new ModelService();
|
const models = new ModelService();
|
||||||
@@ -47,6 +48,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
...newDiagramCommands(ctx),
|
...newDiagramCommands(ctx),
|
||||||
...renderCommands(ctx),
|
...renderCommands(ctx),
|
||||||
...modelCommands(ctx),
|
...modelCommands(ctx),
|
||||||
|
...registerSync(context, models),
|
||||||
);
|
);
|
||||||
registerContextKeys(context, models);
|
registerContextKeys(context, models);
|
||||||
}
|
}
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
Invite,
|
||||||
|
JoinResponse,
|
||||||
|
Member,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
|
ServerInfo,
|
||||||
|
Space,
|
||||||
|
TokenResponse,
|
||||||
|
Version,
|
||||||
|
VersionFiles,
|
||||||
|
} from './protocol';
|
||||||
|
|
||||||
|
export class SyncApiError extends Error {
|
||||||
|
constructor(readonly status: number, readonly code: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server address as the extension keeps it: http(s), no trailing slash.
|
||||||
|
* Plain http is refused except on this machine; tokens travel on it.
|
||||||
|
*/
|
||||||
|
export function checkServerUrl(raw: string): { url?: string; problem?: string } {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw.trim());
|
||||||
|
} catch {
|
||||||
|
return { problem: 'Enter the server address, for example https://archify.example.com' };
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') return { problem: 'The address starts with https://' };
|
||||||
|
if (url.protocol === 'http:' && !LOOPBACK.has(url.hostname)) return { problem: 'Use https://. Plain http:// is only for a server on this machine.' };
|
||||||
|
return { url: `${url.origin}${url.pathname.replace(/\/+$/, '')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncSocketUrl(server: string, space: string): string {
|
||||||
|
return `${server.replace(/^http/, 'ws')}/v1/spaces/${space}/sync?v=${PROTOCOL_VERSION}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads `server` and `invite` from an invite link: `https://…/join/<token>` or the extension's `…/join?server=&invite=`. */
|
||||||
|
export function parseInviteLink(raw: string): { server: string; invite: string } | undefined {
|
||||||
|
try {
|
||||||
|
const url = new URL(raw.trim());
|
||||||
|
const server = url.searchParams.get('server');
|
||||||
|
const invite = url.searchParams.get('invite');
|
||||||
|
if (server && invite) return { server: server.replace(/\/+$/, ''), invite };
|
||||||
|
const match = /^(.*)\/join\/([A-Za-z0-9_-]{20,})\/?$/.exec(url.pathname);
|
||||||
|
if (match && (url.protocol === 'https:' || url.protocol === 'http:')) return { server: `${url.origin}${match[1]}`, invite: match[2] };
|
||||||
|
} catch {
|
||||||
|
// not a URL
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server's HTTP API. `token` gives a fresh access token for calls that need one. */
|
||||||
|
export class SyncApi {
|
||||||
|
constructor(readonly server: string, private readonly token?: (force?: boolean) => Promise<string | undefined>) {}
|
||||||
|
|
||||||
|
private async request<T>(method: string, path: string, body?: unknown, signedIn = true): Promise<T> {
|
||||||
|
const send = async (force: boolean) => {
|
||||||
|
const headers: Record<string, string> = { accept: 'application/json' };
|
||||||
|
if (body !== undefined) headers['content-type'] = 'application/json';
|
||||||
|
if (signedIn) {
|
||||||
|
const token = await this.token?.(force);
|
||||||
|
if (!token) throw new SyncApiError(401, 'unauthorized', 'Sign in to the sync server first.');
|
||||||
|
headers.authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return fetch(`${this.server}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||||
|
};
|
||||||
|
let response = await send(false);
|
||||||
|
// The access token may have expired between the check and the call.
|
||||||
|
if (response.status === 401 && signedIn) response = await send(true);
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
let error: Partial<ApiError> = {};
|
||||||
|
try {
|
||||||
|
error = JSON.parse(text) as ApiError;
|
||||||
|
} catch {
|
||||||
|
// not JSON
|
||||||
|
}
|
||||||
|
throw new SyncApiError(response.status, error.error ?? 'http', error.message ?? `The server answered ${response.status}.`);
|
||||||
|
}
|
||||||
|
return (text ? JSON.parse(text) : undefined) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
info = () => this.request<ServerInfo>('GET', '/v1/info', undefined, false);
|
||||||
|
exchangeCode = (code: string, codeVerifier: string, redirectUri: string) =>
|
||||||
|
this.request<TokenResponse>('POST', '/v1/auth/token', { grant_type: 'authorization_code', code, code_verifier: codeVerifier, redirect_uri: redirectUri }, false);
|
||||||
|
refresh = (refreshToken: string) => this.request<TokenResponse>('POST', '/v1/auth/token', { grant_type: 'refresh_token', refresh_token: refreshToken }, false);
|
||||||
|
logout = (refreshToken: string) => this.request<void>('POST', '/v1/auth/logout', { refresh_token: refreshToken }, false);
|
||||||
|
spaces = () => this.request<Space[]>('GET', '/v1/spaces');
|
||||||
|
createSpace = (name: string) => this.request<Space>('POST', '/v1/spaces', { name });
|
||||||
|
members = (space: string) => this.request<Member[]>('GET', `/v1/spaces/${space}/members`);
|
||||||
|
invite = (space: string) => this.request<Invite>('POST', `/v1/spaces/${space}/invites`, {});
|
||||||
|
acceptInvite = (token: string) => this.request<JoinResponse>('POST', `/v1/invites/${encodeURIComponent(token)}/accept`);
|
||||||
|
versions = (space: string) => this.request<Version[]>('GET', `/v1/spaces/${space}/versions`);
|
||||||
|
publish = (space: string, name: string) => this.request<Version>('POST', `/v1/spaces/${space}/versions`, { name });
|
||||||
|
versionFiles = (space: string, version: number) => this.request<VersionFiles>('GET', `/v1/spaces/${space}/versions/${version}/files`);
|
||||||
|
restore = (space: string, version: number) => this.request<Version>('POST', `/v1/spaces/${space}/versions/${version}/restore`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { SyncApi, SyncApiError } from './api';
|
||||||
|
import { Account, TokenResponse } from './protocol';
|
||||||
|
|
||||||
|
export const AUTH_PROVIDER_ID = 'archify-sync';
|
||||||
|
const SECRET_KEY = 'archifySync.sessions';
|
||||||
|
const SIGN_IN_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
interface StoredSession {
|
||||||
|
id: string;
|
||||||
|
server: string;
|
||||||
|
accessToken: string;
|
||||||
|
/** Epoch ms. */
|
||||||
|
expiresAt: number;
|
||||||
|
refreshToken: string;
|
||||||
|
account: Account;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingSignIn {
|
||||||
|
resolve: (code: string) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Archify Sync" in the Accounts menu: one session per server. Sign-in runs
|
||||||
|
* in the browser and comes back through the extension's URI handler with a
|
||||||
|
* one-time code, exchanged with a PKCE verifier. Tokens live in SecretStorage.
|
||||||
|
* Refresh tokens are single use, so refreshes are serialised per server and
|
||||||
|
* read the latest stored session first (another window may have refreshed).
|
||||||
|
*/
|
||||||
|
export class SyncAuth implements vscode.AuthenticationProvider, vscode.Disposable {
|
||||||
|
private readonly changed = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
|
||||||
|
readonly onDidChangeSessions = this.changed.event;
|
||||||
|
private readonly pending = new Map<string, PendingSignIn>();
|
||||||
|
private readonly refreshing = new Map<string, Promise<string | undefined>>();
|
||||||
|
private readonly disposables: vscode.Disposable[];
|
||||||
|
|
||||||
|
constructor(private readonly context: vscode.ExtensionContext, private readonly log: vscode.LogOutputChannel) {
|
||||||
|
this.disposables = [
|
||||||
|
vscode.authentication.registerAuthenticationProvider(AUTH_PROVIDER_ID, 'Archify Sync', this, { supportsMultipleAccounts: true }),
|
||||||
|
// Another window signed in or out.
|
||||||
|
context.secrets.onDidChange((e) => {
|
||||||
|
if (e.key === SECRET_KEY) this.changed.fire({ added: [], removed: [], changed: [] });
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<StoredSession[]> {
|
||||||
|
try {
|
||||||
|
return JSON.parse((await this.context.secrets.get(SECRET_KEY)) ?? '[]') as StoredSession[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save(sessions: StoredSession[]): Promise<void> {
|
||||||
|
await this.context.secrets.store(SECRET_KEY, JSON.stringify(sessions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSession(s: StoredSession): vscode.AuthenticationSession {
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
accessToken: s.accessToken,
|
||||||
|
account: { id: s.account.id, label: `${s.account.display_name} (${new URL(s.server).host})` },
|
||||||
|
scopes: [s.server],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessions(scopes?: readonly string[]): Promise<vscode.AuthenticationSession[]> {
|
||||||
|
const server = scopes?.[0];
|
||||||
|
return (await this.load()).filter((s) => !server || s.server === server).map((s) => this.toSession(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(scopes: readonly string[]): Promise<vscode.AuthenticationSession> {
|
||||||
|
const server = scopes[0];
|
||||||
|
if (!server) throw new Error('Archify Sync: say which server to sign in to.');
|
||||||
|
return this.toSession(await this.signIn(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSession(id: string): Promise<void> {
|
||||||
|
const sessions = await this.load();
|
||||||
|
const gone = sessions.find((s) => s.id === id);
|
||||||
|
if (!gone) return;
|
||||||
|
await this.save(sessions.filter((s) => s.id !== id));
|
||||||
|
new SyncApi(gone.server).logout(gone.refreshToken).catch(() => undefined);
|
||||||
|
this.changed.fire({ added: [], removed: [this.toSession(gone)], changed: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async session(server: string): Promise<StoredSession | undefined> {
|
||||||
|
return (await this.load()).find((s) => s.server === server);
|
||||||
|
}
|
||||||
|
|
||||||
|
async servers(): Promise<string[]> {
|
||||||
|
return [...new Set((await this.load()).map((s) => s.server))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs in through the browser; with an invite, the page also offers to make an account. */
|
||||||
|
async signIn(server: string, invite?: string): Promise<StoredSession> {
|
||||||
|
const verifier = crypto.randomBytes(32).toString('base64url');
|
||||||
|
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||||
|
const state = crypto.randomBytes(16).toString('base64url');
|
||||||
|
const callback = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://${this.context.extension.id}/auth`));
|
||||||
|
const redirectUri = callback.toString(true);
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
state,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
if (invite) query.set('invite', invite);
|
||||||
|
|
||||||
|
const code = await vscode.window.withProgress(
|
||||||
|
{ location: vscode.ProgressLocation.Notification, title: `Archify: signing in to ${new URL(server).host} in the browser…`, cancellable: true },
|
||||||
|
(_progress, cancel) =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('The sign-in took too long. Try again.')), SIGN_IN_TIMEOUT_MS);
|
||||||
|
const done = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.pending.delete(state);
|
||||||
|
};
|
||||||
|
this.pending.set(state, {
|
||||||
|
resolve: (c) => (done(), resolve(c)),
|
||||||
|
reject: (e) => (done(), reject(e)),
|
||||||
|
});
|
||||||
|
cancel.onCancellationRequested(() => this.pending.get(state)?.reject(new Error('Sign-in cancelled.')));
|
||||||
|
void vscode.env.openExternal(vscode.Uri.parse(`${server}/authorize?${query}`, true)).then((opened) => {
|
||||||
|
if (!opened) this.pending.get(state)?.reject(new Error('The browser did not open.'));
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const tokens = await new SyncApi(server).exchangeCode(code, verifier, redirectUri);
|
||||||
|
const session = await this.store(server, tokens);
|
||||||
|
await this.context.globalState.update('archifySync.used', true);
|
||||||
|
this.log.info(`sync: signed in to ${server} as ${tokens.account.name}`);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The browser came back: `…/auth?code=&state=`. */
|
||||||
|
handleAuthUri(uri: vscode.Uri): void {
|
||||||
|
const query = new URLSearchParams(uri.query);
|
||||||
|
const state = query.get('state') ?? '';
|
||||||
|
const code = query.get('code');
|
||||||
|
const pending = this.pending.get(state);
|
||||||
|
if (!pending) {
|
||||||
|
this.log.warn('sync: a sign-in came back that this window did not start');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code) pending.resolve(code);
|
||||||
|
else pending.reject(new Error(query.get('error_description') ?? 'The sign-in was not completed.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async store(server: string, tokens: TokenResponse, id?: string): Promise<StoredSession> {
|
||||||
|
const session: StoredSession = {
|
||||||
|
id: id ?? crypto.randomUUID(),
|
||||||
|
server,
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
expiresAt: Date.now() + tokens.expires_in * 1000,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
account: tokens.account,
|
||||||
|
};
|
||||||
|
const others = (await this.load()).filter((s) => s.server !== server);
|
||||||
|
await this.save([...others, session]);
|
||||||
|
const event = id ? { added: [], removed: [], changed: [this.toSession(session)] } : { added: [this.toSession(session)], removed: [], changed: [] };
|
||||||
|
this.changed.fire(event);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A valid access token for the server, refreshed when close to expiry; undefined without a session. */
|
||||||
|
accessToken(server: string, force = false): Promise<string | undefined> {
|
||||||
|
const running = this.refreshing.get(server);
|
||||||
|
if (running) return running;
|
||||||
|
const work = this.freshToken(server, force).finally(() => this.refreshing.delete(server));
|
||||||
|
this.refreshing.set(server, work);
|
||||||
|
return work;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async freshToken(server: string, force: boolean): Promise<string | undefined> {
|
||||||
|
const session = await this.session(server);
|
||||||
|
if (!session) return undefined;
|
||||||
|
if (!force && session.expiresAt - 60_000 > Date.now()) return session.accessToken;
|
||||||
|
try {
|
||||||
|
const tokens = await new SyncApi(server).refresh(session.refreshToken);
|
||||||
|
return (await this.store(server, tokens, session.id)).accessToken;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyncApiError && error.status === 401) {
|
||||||
|
// Expired, or signed out elsewhere: the person signs in again.
|
||||||
|
const latest = await this.session(server);
|
||||||
|
if (latest && latest.refreshToken !== session.refreshToken) return latest.accessToken;
|
||||||
|
await this.removeSession(session.id);
|
||||||
|
this.log.warn(`sync: the session for ${server} ended; sign in again`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Offline: keep the old token; the connection waits and tries again.
|
||||||
|
this.log.info(`sync: could not refresh the token for ${server} (${String(error)})`);
|
||||||
|
return session.accessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const pending of this.pending.values()) pending.reject(new Error('Closed.'));
|
||||||
|
for (const d of this.disposables) d.dispose();
|
||||||
|
this.changed.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import WebSocket from 'ws';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import * as awarenessProtocol from 'y-protocols/awareness';
|
||||||
|
import * as syncProtocol from 'y-protocols/sync';
|
||||||
|
import * as decoding from 'lib0/decoding';
|
||||||
|
import * as encoding from 'lib0/encoding';
|
||||||
|
|
||||||
|
const MESSAGE_SYNC = 0;
|
||||||
|
const MESSAGE_AWARENESS = 1;
|
||||||
|
const MESSAGE_QUERY_AWARENESS = 3;
|
||||||
|
|
||||||
|
export type ConnectionState =
|
||||||
|
| 'connecting'
|
||||||
|
| 'online'
|
||||||
|
| 'offline'
|
||||||
|
/** No session for the server, or the server refused it. */
|
||||||
|
| 'signed-out'
|
||||||
|
/** The server no longer lets this account in (removed from the space). */
|
||||||
|
| 'forbidden'
|
||||||
|
/** The server speaks another protocol version. */
|
||||||
|
| 'outdated';
|
||||||
|
|
||||||
|
export interface ConnectionOptions {
|
||||||
|
url: string;
|
||||||
|
/** A fresh access token; `force` after the server refused the last one. */
|
||||||
|
token: (force: boolean) => Promise<string | undefined>;
|
||||||
|
doc: Y.Doc;
|
||||||
|
awareness: awarenessProtocol.Awareness;
|
||||||
|
onState: (state: ConnectionState) => void;
|
||||||
|
/** After the first full exchange of each connection. */
|
||||||
|
onSynced: () => void;
|
||||||
|
log: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One space's WebSocket to the server: the y-websocket protocol (sync step 1
|
||||||
|
* and 2, updates, awareness), reconnecting with backoff. Edits made while
|
||||||
|
* offline go out on reconnect through the state-vector exchange.
|
||||||
|
*/
|
||||||
|
export class LiveConnection {
|
||||||
|
state: ConnectionState = 'connecting';
|
||||||
|
synced = false;
|
||||||
|
private socket?: WebSocket;
|
||||||
|
private attempt = 0;
|
||||||
|
private timer?: NodeJS.Timeout;
|
||||||
|
private closed = false;
|
||||||
|
private forceToken = false;
|
||||||
|
|
||||||
|
constructor(private readonly options: ConnectionOptions) {
|
||||||
|
options.doc.on('update', this.onDocUpdate);
|
||||||
|
options.awareness.on('update', this.onAwarenessUpdate);
|
||||||
|
void this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connects at once, e.g. after signing in. */
|
||||||
|
reconnectNow(): void {
|
||||||
|
if (this.closed || this.state === 'online' || this.state === 'connecting') return;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.attempt = 0;
|
||||||
|
void this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(state: ConnectionState): void {
|
||||||
|
if (this.state === state) return;
|
||||||
|
this.state = state;
|
||||||
|
this.options.onState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connect(): Promise<void> {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.setState('connecting');
|
||||||
|
let token: string | undefined;
|
||||||
|
try {
|
||||||
|
token = await this.options.token(this.forceToken);
|
||||||
|
} catch (error) {
|
||||||
|
this.options.log(`sync: no token (${String(error)})`);
|
||||||
|
}
|
||||||
|
this.forceToken = false;
|
||||||
|
if (this.closed) return;
|
||||||
|
if (!token) {
|
||||||
|
this.setState('signed-out');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const socket = new WebSocket(this.options.url, { headers: { authorization: `Bearer ${token}` }, handshakeTimeout: 10_000 });
|
||||||
|
socket.binaryType = 'arraybuffer';
|
||||||
|
this.socket = socket;
|
||||||
|
let refused = false;
|
||||||
|
|
||||||
|
socket.on('open', () => {
|
||||||
|
const step1 = encoding.createEncoder();
|
||||||
|
encoding.writeVarUint(step1, MESSAGE_SYNC);
|
||||||
|
syncProtocol.writeSyncStep1(step1, this.options.doc);
|
||||||
|
this.send(encoding.toUint8Array(step1));
|
||||||
|
if (this.options.awareness.getLocalState() !== null) this.sendAwareness([this.options.doc.clientID]);
|
||||||
|
});
|
||||||
|
socket.on('message', (data: ArrayBuffer) => this.receive(new Uint8Array(data)));
|
||||||
|
socket.on('unexpected-response', (_request, response) => {
|
||||||
|
refused = true;
|
||||||
|
const status = response.statusCode ?? 0;
|
||||||
|
this.options.log(`sync: the server refused the connection (${status})`);
|
||||||
|
if (status === 401) {
|
||||||
|
// Try once more with a refreshed token; without a session, stop.
|
||||||
|
this.forceToken = true;
|
||||||
|
this.setState('signed-out');
|
||||||
|
this.schedule(true);
|
||||||
|
} else if (status === 403 || status === 404) {
|
||||||
|
this.setState('forbidden');
|
||||||
|
} else if (status === 426) {
|
||||||
|
this.setState('outdated');
|
||||||
|
} else {
|
||||||
|
this.setState('offline');
|
||||||
|
this.schedule();
|
||||||
|
}
|
||||||
|
socket.terminate();
|
||||||
|
});
|
||||||
|
socket.on('error', (error) => this.options.log(`sync: ${error.message}`));
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (this.socket !== socket) return;
|
||||||
|
this.socket = undefined;
|
||||||
|
this.synced = false;
|
||||||
|
const others = [...this.options.awareness.getStates().keys()].filter((id) => id !== this.options.doc.clientID);
|
||||||
|
awarenessProtocol.removeAwarenessStates(this.options.awareness, others, this);
|
||||||
|
if (this.closed || refused) return;
|
||||||
|
this.setState('offline');
|
||||||
|
this.schedule();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backoff: 0.5 s doubling to 30 s, with jitter. */
|
||||||
|
private schedule(once = false): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
if (once && this.attempt > 0) return;
|
||||||
|
const delay = Math.min(30_000, 500 * 2 ** this.attempt) * (0.75 + Math.random() / 2);
|
||||||
|
this.attempt++;
|
||||||
|
this.timer = setTimeout(() => void this.connect(), delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private receive(bytes: Uint8Array): void {
|
||||||
|
const decoder = decoding.createDecoder(bytes);
|
||||||
|
const type = decoding.readVarUint(decoder);
|
||||||
|
if (type === MESSAGE_SYNC) {
|
||||||
|
const reply = encoding.createEncoder();
|
||||||
|
encoding.writeVarUint(reply, MESSAGE_SYNC);
|
||||||
|
const sub = syncProtocol.readSyncMessage(decoder, reply, this.options.doc, this);
|
||||||
|
if (encoding.length(reply) > 1) this.send(encoding.toUint8Array(reply));
|
||||||
|
if (sub === syncProtocol.messageYjsSyncStep2 && !this.synced) {
|
||||||
|
this.synced = true;
|
||||||
|
this.attempt = 0;
|
||||||
|
this.setState('online');
|
||||||
|
this.options.onSynced();
|
||||||
|
}
|
||||||
|
} else if (type === MESSAGE_AWARENESS) {
|
||||||
|
awarenessProtocol.applyAwarenessUpdate(this.options.awareness, decoding.readVarUint8Array(decoder), this);
|
||||||
|
} else if (type === MESSAGE_QUERY_AWARENESS) {
|
||||||
|
this.sendAwareness([...this.options.awareness.getStates().keys()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(bytes: Uint8Array): void {
|
||||||
|
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendAwareness(clients: number[]): void {
|
||||||
|
const message = encoding.createEncoder();
|
||||||
|
encoding.writeVarUint(message, MESSAGE_AWARENESS);
|
||||||
|
encoding.writeVarUint8Array(message, awarenessProtocol.encodeAwarenessUpdate(this.options.awareness, clients));
|
||||||
|
this.send(encoding.toUint8Array(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onDocUpdate = (update: Uint8Array, origin: unknown) => {
|
||||||
|
if (origin === this) return;
|
||||||
|
const message = encoding.createEncoder();
|
||||||
|
encoding.writeVarUint(message, MESSAGE_SYNC);
|
||||||
|
syncProtocol.writeUpdate(message, update);
|
||||||
|
this.send(encoding.toUint8Array(message));
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onAwarenessUpdate = (
|
||||||
|
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
|
||||||
|
origin: unknown,
|
||||||
|
) => {
|
||||||
|
if (origin === this) return;
|
||||||
|
this.sendAwareness([...added, ...updated, ...removed]);
|
||||||
|
};
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.closed = true;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
// Tell the others this editor left before closing.
|
||||||
|
awarenessProtocol.removeAwarenessStates(this.options.awareness, [this.options.doc.clientID], 'dispose');
|
||||||
|
this.options.doc.off('update', this.onDocUpdate);
|
||||||
|
this.options.awareness.off('update', this.onAwarenessUpdate);
|
||||||
|
this.socket?.close();
|
||||||
|
this.socket = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { normalizeEol, offsetOf, positionOf } from '../util/textDiff';
|
||||||
|
import type { LiveCursor, LiveSelection } from './liveBridge';
|
||||||
|
|
||||||
|
/** One colour per person, the same in every window. */
|
||||||
|
const COLORS = ['#e5534b', '#57ab5a', '#539bf5', '#c69026', '#b083f0', '#39c5cf', '#e0823d', '#d16d9e'];
|
||||||
|
|
||||||
|
export function colorFor(id: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return COLORS[hash % COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the text-editor side needs from the shared models. */
|
||||||
|
export interface CursorSource {
|
||||||
|
cursors(file: vscode.Uri): LiveCursor[];
|
||||||
|
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Styles {
|
||||||
|
caret: vscode.TextEditorDecorationType;
|
||||||
|
selection: vscode.TextEditorDecorationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursors in VS Code's text editors: yours go to the others as you move, and
|
||||||
|
* theirs are drawn as a coloured caret with their name and a tinted selection.
|
||||||
|
*/
|
||||||
|
export class EditorCursors implements vscode.Disposable {
|
||||||
|
private readonly styles = new Map<string, Styles>();
|
||||||
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private pending: vscode.TextEditor | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly source: CursorSource) {
|
||||||
|
this.disposables.push(
|
||||||
|
vscode.window.onDidChangeTextEditorSelection((event) => this.moved(event.textEditor)),
|
||||||
|
vscode.window.onDidChangeActiveTextEditor((editor) => editor && this.moved(editor)),
|
||||||
|
vscode.window.onDidChangeVisibleTextEditors(() => this.render()),
|
||||||
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
|
if (vscode.window.visibleTextEditors.some((e) => e.document === event.document)) this.render();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sent at most every 80 ms; the last position wins. */
|
||||||
|
private moved(editor: vscode.TextEditor): void {
|
||||||
|
if (editor.document.uri.scheme !== 'file') return;
|
||||||
|
this.pending = editor;
|
||||||
|
if (this.timer) return;
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = undefined;
|
||||||
|
const e = this.pending;
|
||||||
|
this.pending = undefined;
|
||||||
|
if (!e || e.document.isClosed) return;
|
||||||
|
const text = normalizeEol(e.document.getText());
|
||||||
|
const { anchor, active } = e.selection;
|
||||||
|
this.source.setCursor(e.document.uri, {
|
||||||
|
anchor: offsetOf(text, anchor.line, anchor.character),
|
||||||
|
head: offsetOf(text, active.line, active.character),
|
||||||
|
});
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stylesFor(color: string): Styles {
|
||||||
|
let found = this.styles.get(color);
|
||||||
|
if (!found) {
|
||||||
|
found = {
|
||||||
|
caret: vscode.window.createTextEditorDecorationType({
|
||||||
|
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
|
||||||
|
// A zero-width caret and the name above it; the CSS rides in textDecoration,
|
||||||
|
// the usual way to place a decoration without moving the text.
|
||||||
|
before: {
|
||||||
|
contentText: '',
|
||||||
|
textDecoration: `none; position: absolute; display: inline-block; height: 1.25em; border-left: 2px solid ${color}; margin-left: -1px; z-index: 1;`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
selection: vscode.window.createTextEditorDecorationType({
|
||||||
|
backgroundColor: `${color}38`,
|
||||||
|
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
this.styles.set(color, found);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws everyone's cursors in the visible editors of shared files. */
|
||||||
|
render(): void {
|
||||||
|
for (const editor of vscode.window.visibleTextEditors) {
|
||||||
|
if (editor.document.uri.scheme !== 'file') continue;
|
||||||
|
const cursors = this.source.cursors(editor.document.uri);
|
||||||
|
const text = normalizeEol(editor.document.getText());
|
||||||
|
const at = (offset: number) => {
|
||||||
|
const { line, character } = positionOf(text, Math.max(0, Math.min(offset, text.length)));
|
||||||
|
return new vscode.Position(line, character);
|
||||||
|
};
|
||||||
|
const byColor = new Map<string, { carets: vscode.DecorationOptions[]; selections: vscode.Range[] }>();
|
||||||
|
for (const c of cursors) {
|
||||||
|
let group = byColor.get(c.color);
|
||||||
|
if (!group) byColor.set(c.color, (group = { carets: [], selections: [] }));
|
||||||
|
const head = at(c.head);
|
||||||
|
group.carets.push({
|
||||||
|
range: new vscode.Range(head, head),
|
||||||
|
hoverMessage: new vscode.MarkdownString(`**${c.name.replace(/[\\`*_[\]<>]/g, '')}** is here`),
|
||||||
|
renderOptions: {
|
||||||
|
after: {
|
||||||
|
contentText: c.name,
|
||||||
|
color: '#fff',
|
||||||
|
backgroundColor: c.color,
|
||||||
|
fontWeight: '500',
|
||||||
|
textDecoration: 'none; position: absolute; transform: translateY(-1.15em); font-size: 0.75em; padding: 0 4px; border-radius: 3px; white-space: nowrap; pointer-events: none; z-index: 2; opacity: 0.9;',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (c.anchor !== c.head) {
|
||||||
|
const [from, to] = c.anchor < c.head ? [c.anchor, c.head] : [c.head, c.anchor];
|
||||||
|
group.selections.push(new vscode.Range(at(from), at(to)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const color of byColor.keys()) this.stylesFor(color);
|
||||||
|
// Every colour is set, so a person who left or moved away disappears.
|
||||||
|
for (const [color, styles] of this.styles) {
|
||||||
|
const group = byColor.get(color);
|
||||||
|
editor.setDecorations(styles.caret, group?.carets ?? []);
|
||||||
|
editor.setDecorations(styles.selection, group?.selections ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
for (const styles of this.styles.values()) {
|
||||||
|
styles.caret.dispose();
|
||||||
|
styles.selection.dispose();
|
||||||
|
}
|
||||||
|
this.styles.clear();
|
||||||
|
for (const d of this.disposables.splice(0)) d.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { normalizeEol, positionOf } from '../util/textDiff';
|
||||||
|
import { FILES_MAP } from './protocol';
|
||||||
|
import { applyToYText, safeChange } from './textSync';
|
||||||
|
|
||||||
|
const MODEL_GLOB = '**/*.{archify,archify-registry,archify-decision}';
|
||||||
|
const SKIP_GLOB = '{**/node_modules/**,**/.git/**}';
|
||||||
|
const MODEL_FILE = /\.(archify|archify-registry|archify-decision)$/i;
|
||||||
|
|
||||||
|
/** A model-root-relative path the bridge accepts: POSIX, no `..`, a model file. */
|
||||||
|
export function isSafeModelPath(path: string): boolean {
|
||||||
|
if (!MODEL_FILE.test(path) || path.startsWith('/') || path.includes('\\')) return false;
|
||||||
|
return path.split('/').every((part) => part !== '' && part !== '.' && part !== '..' && part !== '.git' && part !== 'node_modules');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a model's files and the space's Y.Text per file the same.
|
||||||
|
*
|
||||||
|
* Local edits (any editor, the Source view, a drag on a diagram, git, an
|
||||||
|
* agent) become minimal Y.Text edits. Remote edits go back as WorkspaceEdits
|
||||||
|
* for open documents, so undo, dirty state and the pages stay right, and as
|
||||||
|
* file writes for closed ones. Each side compares texts before writing, so
|
||||||
|
* an edit coming back from the other side ends as a no-op instead of an echo.
|
||||||
|
*/
|
||||||
|
export class FileBridge implements vscode.Disposable {
|
||||||
|
readonly files: Y.Map<Y.Text>;
|
||||||
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
|
private queue: Promise<void> = Promise.resolve();
|
||||||
|
private listening = false;
|
||||||
|
/**
|
||||||
|
* Text the bridge last wrote per file. Its own write comes back as a change
|
||||||
|
* event, possibly after the document moved on; pushing it then would undo
|
||||||
|
* the newer edit, so it is skipped once.
|
||||||
|
*/
|
||||||
|
private readonly written = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly root: vscode.Uri,
|
||||||
|
private readonly doc: Y.Doc,
|
||||||
|
/** Transaction origin of edits made here. */
|
||||||
|
private readonly origin: object,
|
||||||
|
private readonly log: vscode.LogOutputChannel,
|
||||||
|
) {
|
||||||
|
this.files = doc.getMap<Y.Text>(FILES_MAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The model-relative path of a file in this model, else undefined. */
|
||||||
|
relative(uri: vscode.Uri): string | undefined {
|
||||||
|
if (uri.scheme !== this.root.scheme) return undefined;
|
||||||
|
const base = this.root.path.endsWith('/') ? this.root.path : `${this.root.path}/`;
|
||||||
|
if (!uri.path.startsWith(base)) return undefined;
|
||||||
|
const rel = uri.path.slice(base.length);
|
||||||
|
return isSafeModelPath(rel) ? rel : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
uriOf(rel: string): vscode.Uri {
|
||||||
|
return vscode.Uri.joinPath(this.root, ...rel.split('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remote edits start going to disk; with `local`, local edits start going out. */
|
||||||
|
start(local: boolean): void {
|
||||||
|
if (!this.disposables.length) {
|
||||||
|
const observer = (events: Y.YEvent<Y.AbstractType<unknown>>[], txn: Y.Transaction) => {
|
||||||
|
if (txn.origin === this.origin) return;
|
||||||
|
const paths = new Set<string>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.target === this.files) for (const key of event.changes.keys.keys()) paths.add(key);
|
||||||
|
else if (typeof event.path[0] === 'string') paths.add(event.path[0]);
|
||||||
|
}
|
||||||
|
for (const path of paths) this.enqueue(() => this.writeOut(path));
|
||||||
|
};
|
||||||
|
this.files.observeDeep(observer);
|
||||||
|
this.disposables.push({ dispose: () => this.files.unobserveDeep(observer) });
|
||||||
|
}
|
||||||
|
if (local && !this.listening) {
|
||||||
|
this.listening = true;
|
||||||
|
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(this.root, MODEL_GLOB));
|
||||||
|
this.disposables.push(
|
||||||
|
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||||
|
const rel = this.relative(e.document.uri);
|
||||||
|
if (rel && e.contentChanges.length) this.pushLocal(rel, normalizeEol(e.document.getText()));
|
||||||
|
}),
|
||||||
|
watcher,
|
||||||
|
watcher.onDidCreate((uri) => this.enqueue(() => this.pushFromDisk(uri))),
|
||||||
|
watcher.onDidChange((uri) => this.enqueue(() => this.pushFromDisk(uri))),
|
||||||
|
watcher.onDidDelete((uri) => {
|
||||||
|
const rel = this.relative(uri);
|
||||||
|
if (rel && this.files.has(rel)) this.enqueue(async () => {
|
||||||
|
// Gone for good, not an editor's save-by-rename.
|
||||||
|
if (!(await exists(uri))) this.doc.transact(() => this.files.delete(rel), this.origin);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushLocal(rel: string, text: string): void {
|
||||||
|
if (this.written.get(rel) === text) {
|
||||||
|
this.written.delete(rel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.push(rel, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One file's text into the document. */
|
||||||
|
push(rel: string, text: string): void {
|
||||||
|
const existing = this.files.get(rel);
|
||||||
|
if (existing instanceof Y.Text) {
|
||||||
|
if (existing.toString() !== text) this.doc.transact(() => applyToYText(existing, text), this.origin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.doc.transact(() => {
|
||||||
|
const created = new Y.Text();
|
||||||
|
created.insert(0, text);
|
||||||
|
this.files.set(rel, created);
|
||||||
|
}, this.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pushFromDisk(uri: vscode.Uri): Promise<void> {
|
||||||
|
const rel = this.relative(uri);
|
||||||
|
// Open documents report through their change events.
|
||||||
|
if (!rel || vscode.workspace.textDocuments.some((d) => d.uri.toString() === uri.toString())) return;
|
||||||
|
const text = await readDisk(uri);
|
||||||
|
if (text !== undefined) this.pushLocal(rel, normalizeEol(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The model files as they are now: open editors' text, else the disk. */
|
||||||
|
async scan(): Promise<Map<string, string>> {
|
||||||
|
const found = await vscode.workspace.findFiles(new vscode.RelativePattern(this.root, MODEL_GLOB), SKIP_GLOB);
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
for (const uri of found) {
|
||||||
|
const rel = this.relative(uri);
|
||||||
|
if (!rel) continue;
|
||||||
|
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
|
||||||
|
const text = open ? open.getText() : await readDisk(uri);
|
||||||
|
if (text !== undefined) out.set(rel, normalizeEol(text));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings changes made while the extension was not running into the
|
||||||
|
* document (git pull, an edit in another tool). With `deleteMissing`,
|
||||||
|
* files in the document but gone from disk are removed.
|
||||||
|
*/
|
||||||
|
async pushAll(deleteMissing: boolean): Promise<void> {
|
||||||
|
const local = await this.scan();
|
||||||
|
for (const [rel, text] of local) this.push(rel, text);
|
||||||
|
if (deleteMissing) {
|
||||||
|
const gone = [...this.files.keys()].filter((rel) => !local.has(rel));
|
||||||
|
if (gone.length) this.doc.transact(() => gone.forEach((rel) => this.files.delete(rel)), this.origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes every file of the document to disk; then sends local files the document lacks. */
|
||||||
|
async pullAll(): Promise<void> {
|
||||||
|
for (const rel of this.files.keys()) await this.enqueue(() => this.writeOut(rel));
|
||||||
|
const local = await this.scan();
|
||||||
|
for (const [rel, text] of local) if (!this.files.has(rel)) this.push(rel, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueue(work: () => Promise<void>): Promise<void> {
|
||||||
|
this.queue = this.queue.then(work).catch((error) => this.log.error(`sync: ${error instanceof Error ? error.message : String(error)}`));
|
||||||
|
return this.queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The document's text of one file onto disk or into its open editor. */
|
||||||
|
private async writeOut(rel: string): Promise<void> {
|
||||||
|
if (!isSafeModelPath(rel)) {
|
||||||
|
this.log.warn(`sync: ignored a file outside the model: ${rel}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uri = this.uriOf(rel);
|
||||||
|
const value = this.files.get(rel);
|
||||||
|
if (!(value instanceof Y.Text)) {
|
||||||
|
if (await exists(uri)) await vscode.workspace.fs.delete(uri, { useTrash: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = value.toString();
|
||||||
|
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
|
||||||
|
if (open) {
|
||||||
|
const current = normalizeEol(open.getText());
|
||||||
|
const change = safeChange(current, text);
|
||||||
|
if (!change) return;
|
||||||
|
const wasDirty = open.isDirty;
|
||||||
|
const from = positionOf(current, change.from);
|
||||||
|
const to = positionOf(current, change.to);
|
||||||
|
this.written.set(rel, text);
|
||||||
|
const edit = new vscode.WorkspaceEdit();
|
||||||
|
edit.replace(uri, new vscode.Range(from.line, from.character, to.line, to.character), change.insert);
|
||||||
|
if (!(await vscode.workspace.applyEdit(edit))) {
|
||||||
|
this.log.warn(`sync: could not apply a teammate's edit to ${rel}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Teammates' edits land on disk, where git sees them; the person's own unsaved work stays unsaved.
|
||||||
|
if (!wasDirty) await open.save();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const disk = await readDisk(uri);
|
||||||
|
const eol = disk?.includes('\r\n') ? '\r\n' : '\n';
|
||||||
|
const out = eol === '\n' ? text : text.replace(/\n/g, '\r\n');
|
||||||
|
if (disk === out) return;
|
||||||
|
this.written.set(rel, text);
|
||||||
|
await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(uri, '..'));
|
||||||
|
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const d of this.disposables.splice(0)) d.dispose();
|
||||||
|
this.listening = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(uri: vscode.Uri): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await vscode.workspace.fs.stat(uri);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readDisk(uri: vscode.Uri): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
// Archify Sync in the extension: live, shared models. Bundled on its own
|
||||||
|
// (dist/sync.js) and loaded only when a model is shared or a sync command
|
||||||
|
// runs, so the extension without a server works exactly as before.
|
||||||
|
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { MAIN_FILE } from '../model/format';
|
||||||
|
import type { ModelService } from '../model/service';
|
||||||
|
import { SyncApi, SyncApiError, checkServerUrl, parseInviteLink } from './api';
|
||||||
|
import { SyncAuth } from './auth';
|
||||||
|
import { LiveSpace, StartMode } from './liveSpace';
|
||||||
|
import { LINK_FILE, Link, PROTOCOL_VERSION, Version, formatLink, parseLink, versionLabel } from './protocol';
|
||||||
|
import { EditorCursors } from './cursors';
|
||||||
|
import type { LiveAction, LiveBridge, LiveCursor, LivePanel, LiveProvider, LiveSelection } from './liveBridge';
|
||||||
|
import { VERSION_SCHEME, VersionContent, compareVersion, restoreVersion, showVersions } from './versions';
|
||||||
|
|
||||||
|
export const SYNC_COMMANDS = [
|
||||||
|
'archify.sync.signIn',
|
||||||
|
'archify.sync.signOut',
|
||||||
|
'archify.sync.share',
|
||||||
|
'archify.sync.invite',
|
||||||
|
'archify.sync.join',
|
||||||
|
'archify.sync.publish',
|
||||||
|
'archify.sync.versions',
|
||||||
|
'archify.sync.stop',
|
||||||
|
'archify.sync.menu',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SKIP = '{**/node_modules/**,**/.git/**}';
|
||||||
|
/** How long the model page's list of versions is reused before it is asked for again. */
|
||||||
|
const VERSIONS_TTL_MS = 20_000;
|
||||||
|
|
||||||
|
export class SyncFeature implements vscode.Disposable, LiveProvider {
|
||||||
|
private readonly auth: SyncAuth;
|
||||||
|
private readonly spaces = new Map<string, LiveSpace>();
|
||||||
|
private readonly status = vscode.window.createStatusBarItem('archify.sync', vscode.StatusBarAlignment.Left, 50);
|
||||||
|
private readonly content: VersionContent;
|
||||||
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
|
private readonly versionCache = new Map<string, { at: number; versions: Promise<Version[]> }>();
|
||||||
|
private readonly editorCursors: EditorCursors;
|
||||||
|
private cursorTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly context: vscode.ExtensionContext,
|
||||||
|
private readonly models: ModelService,
|
||||||
|
private readonly log: vscode.LogOutputChannel,
|
||||||
|
private readonly bridge?: LiveBridge,
|
||||||
|
) {
|
||||||
|
this.auth = new SyncAuth(context, log);
|
||||||
|
this.content = new VersionContent((space) => {
|
||||||
|
const live = [...this.spaces.values()].find((s) => s.link.space === space);
|
||||||
|
return live ? this.api(live.link.server) : undefined;
|
||||||
|
});
|
||||||
|
this.status.name = 'Archify Sync';
|
||||||
|
this.status.command = 'archify.sync.menu';
|
||||||
|
const links = vscode.workspace.createFileSystemWatcher(`**/${LINK_FILE}`);
|
||||||
|
this.disposables.push(
|
||||||
|
this.auth,
|
||||||
|
this.status,
|
||||||
|
links,
|
||||||
|
links.onDidCreate((uri) => void this.open(uri, 'open')),
|
||||||
|
links.onDidDelete((uri) => this.close(vscode.Uri.joinPath(uri, '..'), false)),
|
||||||
|
vscode.workspace.registerTextDocumentContentProvider(VERSION_SCHEME, this.content),
|
||||||
|
this.auth.onDidChangeSessions(() => {
|
||||||
|
for (const space of this.spaces.values()) space.reconnect();
|
||||||
|
this.render();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.editorCursors = new EditorCursors({ cursors: (file) => this.cursors(file), setCursor: (file, selection) => this.setCursorIn(file, selection) });
|
||||||
|
this.disposables.push(this.editorCursors);
|
||||||
|
bridge?.setProvider(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens every shared model in the workspace. */
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const found = await vscode.workspace.findFiles(`**/${LINK_FILE}`, SKIP, 50);
|
||||||
|
await Promise.all(found.map((uri) => this.open(uri, 'open')));
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private api(server: string): SyncApi {
|
||||||
|
return new SyncApi(server, (force) => this.auth.accessToken(server, force));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async open(linkUri: vscode.Uri, mode: StartMode): Promise<LiveSpace | undefined> {
|
||||||
|
const root = vscode.Uri.joinPath(linkUri, '..');
|
||||||
|
if (this.spaces.has(root.toString())) return this.spaces.get(root.toString());
|
||||||
|
let link: Link | undefined;
|
||||||
|
try {
|
||||||
|
link = parseLink(new TextDecoder().decode(await vscode.workspace.fs.readFile(linkUri)));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!link) {
|
||||||
|
this.log.warn(`sync: ${vscode.workspace.asRelativePath(linkUri)} is not a valid link file`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!checkServerUrl(link.server).url) {
|
||||||
|
this.log.warn(`sync: ${vscode.workspace.asRelativePath(linkUri)} names a server that is not https: ${link.server}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const space = new LiveSpace(root, link, mode, {
|
||||||
|
context: this.context,
|
||||||
|
auth: this.auth,
|
||||||
|
log: this.log,
|
||||||
|
onChange: () => this.render(),
|
||||||
|
onCursors: () => this.cursorsMoved(),
|
||||||
|
});
|
||||||
|
this.spaces.set(root.toString(), space);
|
||||||
|
try {
|
||||||
|
await space.start();
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error(`sync: ${link.name ?? root.fsPath} did not start: ${String(error)}`);
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
return space;
|
||||||
|
}
|
||||||
|
|
||||||
|
private close(root: vscode.Uri, forget: boolean): void {
|
||||||
|
const space = this.spaces.get(root.toString());
|
||||||
|
if (!space) return;
|
||||||
|
this.spaces.delete(root.toString());
|
||||||
|
if (forget) space.forget();
|
||||||
|
else space.dispose();
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(): void {
|
||||||
|
const spaces = [...this.spaces.values()];
|
||||||
|
if (!spaces.length) {
|
||||||
|
this.status.hide();
|
||||||
|
void vscode.commands.executeCommand('setContext', 'archify.sync.active', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void vscode.commands.executeCommand('setContext', 'archify.sync.active', true);
|
||||||
|
const signedOut = spaces.filter((s) => s.state === 'signed-out');
|
||||||
|
const offline = spaces.filter((s) => s.state !== 'online' && s.state !== 'signed-out');
|
||||||
|
const people = new Set(spaces.flatMap((s) => s.people().map((p) => p.user.id)));
|
||||||
|
if (signedOut.length) {
|
||||||
|
this.status.text = '$(account) Archify: sign in';
|
||||||
|
this.status.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||||
|
} else if (offline.length) {
|
||||||
|
this.status.text = offline.some((s) => s.state === 'connecting') ? '$(sync~spin) Archify connecting' : '$(cloud-offline) Archify offline';
|
||||||
|
this.status.backgroundColor = undefined;
|
||||||
|
} else {
|
||||||
|
this.status.text = `$(broadcast) Archify live · ${people.size} here`;
|
||||||
|
this.status.backgroundColor = undefined;
|
||||||
|
}
|
||||||
|
const tooltip = new vscode.MarkdownString(undefined, true);
|
||||||
|
for (const s of spaces) {
|
||||||
|
const who = s.people().map((p) => `${p.user.name}${p.file ? ` (${p.file})` : ''}`).join(', ');
|
||||||
|
tooltip.appendMarkdown(`**${s.name}**: ${s.state}${who ? ` \n${who}` : ''}\n\n`);
|
||||||
|
}
|
||||||
|
tooltip.appendMarkdown('Edits are kept on this machine first and sent when online.');
|
||||||
|
this.status.tooltip = tooltip;
|
||||||
|
this.status.show();
|
||||||
|
this.bridge?.changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------- the model page's Live section
|
||||||
|
|
||||||
|
private versionsOf(space: LiveSpace, fresh = false): Promise<Version[]> {
|
||||||
|
const cached = this.versionCache.get(space.link.space);
|
||||||
|
if (!fresh && cached && Date.now() - cached.at < VERSIONS_TTL_MS) return cached.versions;
|
||||||
|
const versions = this.api(space.link.server).versions(space.link.space);
|
||||||
|
this.versionCache.set(space.link.space, { at: Date.now(), versions });
|
||||||
|
versions.catch(() => this.versionCache.delete(space.link.space));
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After a publish or a restore: the page shows the new version. */
|
||||||
|
private versionsChanged(space: LiveSpace): void {
|
||||||
|
this.versionCache.delete(space.link.space);
|
||||||
|
this.bridge?.changed(space.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
async panel(root: vscode.Uri): Promise<LivePanel | undefined> {
|
||||||
|
const space = this.spaces.get(root.toString());
|
||||||
|
if (!space) return undefined;
|
||||||
|
const me = (space.awareness.getLocalState() as { user?: { id?: string } } | null)?.user?.id;
|
||||||
|
const panel: LivePanel = {
|
||||||
|
name: space.name,
|
||||||
|
host: new URL(space.link.server).host,
|
||||||
|
state: space.state,
|
||||||
|
people: space.people().map((p) => ({ name: p.user.name, file: p.file, me: p.user.id === me })),
|
||||||
|
};
|
||||||
|
if (space.state === 'signed-out' || space.state === 'forbidden') return panel;
|
||||||
|
try {
|
||||||
|
const versions = await this.versionsOf(space);
|
||||||
|
const label = new Map(versions.map((v) => [v.id, versionLabel(v)]));
|
||||||
|
panel.versions = versions.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
label: versionLabel(v),
|
||||||
|
kind: v.kind,
|
||||||
|
name: v.name ?? undefined,
|
||||||
|
at: v.created_at,
|
||||||
|
restoredFrom: v.restored_from !== null ? label.get(v.restored_from) : undefined,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
panel.versionsProblem = space.state === 'online' ? `Versions could not be loaded: ${error instanceof Error ? error.message : String(error)}` : 'Versions show when the server can be reached.';
|
||||||
|
}
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cursors drawn at most every 50 ms, however many people move. */
|
||||||
|
private cursorsMoved(): void {
|
||||||
|
if (this.cursorTimer) return;
|
||||||
|
this.cursorTimer = setTimeout(() => {
|
||||||
|
this.cursorTimer = undefined;
|
||||||
|
this.bridge?.cursorsChanged();
|
||||||
|
this.editorCursors.render();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private spaceOf(file: vscode.Uri): LiveSpace | undefined {
|
||||||
|
for (const space of this.spaces.values()) {
|
||||||
|
const root = space.root.path.endsWith('/') ? space.root.path : `${space.root.path}/`;
|
||||||
|
if (file.scheme === space.root.scheme && file.path.startsWith(root)) return space;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursors(file: vscode.Uri): LiveCursor[] {
|
||||||
|
return this.spaceOf(file)?.cursors(file) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void {
|
||||||
|
this.setCursorIn(file, selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCursorIn(file: vscode.Uri, selection: LiveSelection | undefined): boolean {
|
||||||
|
return this.spaceOf(file)?.setCursor(file, selection) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async act(root: vscode.Uri, action: LiveAction): Promise<void> {
|
||||||
|
const space = this.spaces.get(root.toString());
|
||||||
|
if (!space) return;
|
||||||
|
try {
|
||||||
|
switch (action.kind) {
|
||||||
|
case 'publish':
|
||||||
|
await this.publish(space);
|
||||||
|
break;
|
||||||
|
case 'invite':
|
||||||
|
await this.invite(space);
|
||||||
|
break;
|
||||||
|
case 'signIn':
|
||||||
|
if (await this.checkServer(space.link.server)) await this.auth.signIn(space.link.server);
|
||||||
|
break;
|
||||||
|
case 'versions':
|
||||||
|
await showVersions(space, this.api(space.link.server), this.content);
|
||||||
|
this.versionsChanged(space);
|
||||||
|
break;
|
||||||
|
case 'compare':
|
||||||
|
case 'restore': {
|
||||||
|
const version = (await this.versionsOf(space)).find((v) => v.id === action.version);
|
||||||
|
if (!version) throw new Error('That version is no longer listed.');
|
||||||
|
if (action.kind === 'compare') await compareVersion(space, version, this.content);
|
||||||
|
else if (await restoreVersion(space, this.api(space.link.server), version)) this.versionsChanged(space);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.report(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- commands
|
||||||
|
|
||||||
|
async run(command: (typeof SYNC_COMMANDS)[number], ...args: unknown[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
switch (command) {
|
||||||
|
case 'archify.sync.signIn':
|
||||||
|
await this.signInCommand();
|
||||||
|
break;
|
||||||
|
case 'archify.sync.signOut':
|
||||||
|
await this.signOut();
|
||||||
|
break;
|
||||||
|
case 'archify.sync.share':
|
||||||
|
await this.share(args[0]);
|
||||||
|
break;
|
||||||
|
case 'archify.sync.invite':
|
||||||
|
await this.invite(args[0]);
|
||||||
|
break;
|
||||||
|
case 'archify.sync.join':
|
||||||
|
await this.join(typeof args[0] === 'string' ? args[0] : undefined);
|
||||||
|
break;
|
||||||
|
case 'archify.sync.publish':
|
||||||
|
await this.publish(args[0]);
|
||||||
|
break;
|
||||||
|
case 'archify.sync.versions': {
|
||||||
|
const space = await this.pickSpace(args[0]);
|
||||||
|
if (space) {
|
||||||
|
await showVersions(space, this.api(space.link.server), this.content);
|
||||||
|
this.versionsChanged(space);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'archify.sync.stop':
|
||||||
|
await this.stop(args[0]);
|
||||||
|
break;
|
||||||
|
case 'archify.sync.menu':
|
||||||
|
await this.menu();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.report(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private report(error: unknown): void {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (/cancelled/i.test(message)) return;
|
||||||
|
this.log.error(`sync: ${message}`);
|
||||||
|
void vscode.window.showErrorMessage(`Archify Sync: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The invite and sign-in links: `…/auth?code=&state=`, `…/join?server=&invite=`. */
|
||||||
|
async handleUri(uri: vscode.Uri): Promise<void> {
|
||||||
|
if (uri.path === '/auth') this.auth.handleAuthUri(uri);
|
||||||
|
else if (uri.path === '/join') await this.run('archify.sync.join', uri.toString(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async askServer(): Promise<string | undefined> {
|
||||||
|
const last = this.context.globalState.get<string>('archifySync.lastServer');
|
||||||
|
const value = await vscode.window.showInputBox({
|
||||||
|
title: 'Archify | Sync server',
|
||||||
|
prompt: 'The address of the Archify Sync server',
|
||||||
|
placeHolder: 'https://archify.example.com',
|
||||||
|
value: last,
|
||||||
|
ignoreFocusOut: true,
|
||||||
|
validateInput: (v) => checkServerUrl(v).problem,
|
||||||
|
});
|
||||||
|
return value === undefined ? undefined : checkServerUrl(value).url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks the server answers and speaks this protocol; offers its setup page when it is new. */
|
||||||
|
private async checkServer(server: string): Promise<boolean> {
|
||||||
|
const info = await new SyncApi(server).info();
|
||||||
|
if (info.protocol !== PROTOCOL_VERSION) {
|
||||||
|
throw new Error(`${info.name} speaks protocol ${info.protocol}; this extension speaks ${PROTOCOL_VERSION}. Update ${info.protocol > PROTOCOL_VERSION ? 'the extension' : 'the server'}.`);
|
||||||
|
}
|
||||||
|
if (!info.set_up) {
|
||||||
|
const open = await vscode.window.showInformationMessage(
|
||||||
|
`${info.name} is new: its first admin sets it up with the first-start code from the server's log.`,
|
||||||
|
'Open setup page',
|
||||||
|
);
|
||||||
|
if (open) await vscode.env.openExternal(vscode.Uri.parse(`${server}/setup`));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await this.context.globalState.update('archifySync.lastServer', server);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async signInCommand(): Promise<void> {
|
||||||
|
const server = await this.askServer();
|
||||||
|
if (!server || !(await this.checkServer(server))) return;
|
||||||
|
const session = await this.auth.signIn(server);
|
||||||
|
vscode.window.showInformationMessage(`Archify: signed in to ${new URL(server).host} as ${session.account.display_name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async signOut(): Promise<void> {
|
||||||
|
const servers = await this.auth.servers();
|
||||||
|
if (!servers.length) {
|
||||||
|
vscode.window.showInformationMessage('Archify: not signed in to a sync server.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const server = servers.length === 1 ? servers[0] : await vscode.window.showQuickPick(servers, { title: 'Archify | Sign out of' });
|
||||||
|
const session = server ? await this.auth.session(server) : undefined;
|
||||||
|
if (session) await this.auth.removeSession(session.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A server with a session: the only one, a picked one, or a new sign-in. */
|
||||||
|
private async serverWithSession(): Promise<string | undefined> {
|
||||||
|
const servers = await this.auth.servers();
|
||||||
|
if (servers.length === 1) return servers[0];
|
||||||
|
if (servers.length > 1) {
|
||||||
|
const pick = await vscode.window.showQuickPick([...servers, '$(add) Another server…'], { title: 'Archify | Share to which server?' });
|
||||||
|
if (pick && !pick.startsWith('$(add)')) return pick;
|
||||||
|
if (!pick) return undefined;
|
||||||
|
}
|
||||||
|
const server = await this.askServer();
|
||||||
|
if (!server || !(await this.checkServer(server))) return undefined;
|
||||||
|
await this.auth.signIn(server);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The model root a command applies to: the argument, the active file, or a pick. */
|
||||||
|
private async modelRoot(arg: unknown): Promise<vscode.Uri | undefined> {
|
||||||
|
const uri = arg instanceof vscode.Uri ? arg : vscode.window.activeTextEditor?.document.uri ?? activeTabUri();
|
||||||
|
const root = uri ? await this.models.rootOf(uri) : undefined;
|
||||||
|
if (root) return root;
|
||||||
|
const mains = await vscode.workspace.findFiles(`**/${MAIN_FILE}`, SKIP, 50);
|
||||||
|
if (mains.length === 1) return vscode.Uri.joinPath(mains[0], '..');
|
||||||
|
const pick = await vscode.window.showQuickPick(
|
||||||
|
mains.map((m) => ({ label: vscode.workspace.asRelativePath(vscode.Uri.joinPath(m, '..')), root: vscode.Uri.joinPath(m, '..') })),
|
||||||
|
{ title: 'Archify | Which model?' },
|
||||||
|
);
|
||||||
|
return pick?.root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pickSpace(arg: unknown): Promise<LiveSpace | undefined> {
|
||||||
|
if (arg instanceof LiveSpace) return arg;
|
||||||
|
const spaces = [...this.spaces.values()];
|
||||||
|
if (!spaces.length) {
|
||||||
|
vscode.window.showInformationMessage('Archify: no model in this workspace is shared. Share one with "Archify: Share Model…".');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const uri = arg instanceof vscode.Uri ? arg : vscode.window.activeTextEditor?.document.uri ?? activeTabUri();
|
||||||
|
const root = uri ? await this.models.rootOf(uri) : undefined;
|
||||||
|
const here = root && this.spaces.get(root.toString());
|
||||||
|
if (here) return here;
|
||||||
|
if (spaces.length === 1) return spaces[0];
|
||||||
|
const pick = await vscode.window.showQuickPick(spaces.map((s) => ({ label: s.name, description: new URL(s.link.server).host, space: s })), {
|
||||||
|
title: 'Archify | Which shared model?',
|
||||||
|
});
|
||||||
|
return pick?.space;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async share(arg: unknown): Promise<void> {
|
||||||
|
const root = await this.modelRoot(arg);
|
||||||
|
if (!root) return;
|
||||||
|
if (this.spaces.has(root.toString())) {
|
||||||
|
await this.invite(root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const server = await this.serverWithSession();
|
||||||
|
if (!server) return;
|
||||||
|
const index = await this.models.index(root).catch(() => undefined);
|
||||||
|
const name = await vscode.window.showInputBox({
|
||||||
|
title: 'Archify | Share Model',
|
||||||
|
prompt: `Name of the shared model on ${new URL(server).host}`,
|
||||||
|
value: index?.meta.name ?? root.path.split('/').pop(),
|
||||||
|
validateInput: (v) => (v.trim() ? undefined : 'Give it a name.'),
|
||||||
|
});
|
||||||
|
if (!name) return;
|
||||||
|
const space = await this.api(server).createSpace(name.trim());
|
||||||
|
const linkUri = vscode.Uri.joinPath(root, LINK_FILE);
|
||||||
|
await vscode.workspace.fs.writeFile(linkUri, new TextEncoder().encode(formatLink({ server, space: space.id, name: space.name })));
|
||||||
|
await this.open(linkUri, 'share');
|
||||||
|
const next = await vscode.window.showInformationMessage(
|
||||||
|
`Archify: ${space.name} is shared. Edits now go to everyone you invite.`,
|
||||||
|
'Copy invite link',
|
||||||
|
);
|
||||||
|
if (next) await this.invite(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async invite(arg: unknown): Promise<void> {
|
||||||
|
const space = await this.pickSpace(arg);
|
||||||
|
if (!space) return;
|
||||||
|
const invite = await this.api(space.link.server).invite(space.link.space);
|
||||||
|
await vscode.env.clipboard.writeText(invite.url);
|
||||||
|
vscode.window.showInformationMessage(
|
||||||
|
`Archify: invite link for ${space.name} copied. It works until ${new Date(invite.expires_at).toLocaleDateString()}; send it through any channel.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async join(raw?: string): Promise<void> {
|
||||||
|
const text =
|
||||||
|
raw ??
|
||||||
|
(await vscode.window.showInputBox({
|
||||||
|
title: 'Archify | Join Shared Model',
|
||||||
|
prompt: 'Paste the invite link',
|
||||||
|
ignoreFocusOut: true,
|
||||||
|
validateInput: (v) => (parseInviteLink(v) ? undefined : 'This is not an Archify invite link.'),
|
||||||
|
}));
|
||||||
|
if (!text) return;
|
||||||
|
const parsed = parseInviteLink(text);
|
||||||
|
const server = parsed && checkServerUrl(parsed.server).url;
|
||||||
|
if (!parsed || !server) throw new Error('This is not an Archify invite link.');
|
||||||
|
if (!(await this.checkServer(server))) return;
|
||||||
|
|
||||||
|
const folder = await vscode.window.showOpenDialog({
|
||||||
|
title: 'Archify | Folder for the shared model',
|
||||||
|
openLabel: 'Put the model here',
|
||||||
|
canSelectFiles: false,
|
||||||
|
canSelectFolders: true,
|
||||||
|
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
|
||||||
|
});
|
||||||
|
const root = folder?.[0];
|
||||||
|
if (!root) return;
|
||||||
|
const existing = await vscode.workspace.findFiles(new vscode.RelativePattern(root, '**/*.{archify,archify-registry,archify-decision}'), SKIP, 1);
|
||||||
|
if (existing.length) {
|
||||||
|
throw new Error('That folder already holds Archify files. Pick an empty folder for the shared model.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await this.auth.session(server))) await this.auth.signIn(server, parsed.invite);
|
||||||
|
const joined = await this.api(server).acceptInvite(parsed.invite);
|
||||||
|
const linkUri = vscode.Uri.joinPath(root, LINK_FILE);
|
||||||
|
await vscode.workspace.fs.writeFile(linkUri, new TextEncoder().encode(formatLink({ server, space: joined.space.id, name: joined.space.name })));
|
||||||
|
|
||||||
|
const inWorkspace = vscode.workspace.getWorkspaceFolder(root);
|
||||||
|
if (inWorkspace) {
|
||||||
|
await this.open(linkUri, 'join');
|
||||||
|
vscode.window.showInformationMessage(`Archify: you joined ${joined.space.name}. Its files are arriving in ${vscode.workspace.asRelativePath(root)}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const open = await vscode.window.showInformationMessage(
|
||||||
|
`Archify: you joined ${joined.space.name}. Open its folder to start working together.`,
|
||||||
|
'Open folder',
|
||||||
|
'Open in new window',
|
||||||
|
);
|
||||||
|
if (open) await vscode.commands.executeCommand('vscode.openFolder', root, { forceNewWindow: open === 'Open in new window' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async publish(arg: unknown): Promise<void> {
|
||||||
|
const space = await this.pickSpace(arg);
|
||||||
|
if (!space) return;
|
||||||
|
const name = await vscode.window.showInputBox({
|
||||||
|
title: `Archify | Publish ${space.name}`,
|
||||||
|
prompt: 'Name this version, for example "Q4 baseline"',
|
||||||
|
validateInput: (v) => (v.trim() ? undefined : 'Give the version a name.'),
|
||||||
|
});
|
||||||
|
if (!name) return;
|
||||||
|
const version = await this.api(space.link.server).publish(space.link.space, name.trim());
|
||||||
|
this.versionsChanged(space);
|
||||||
|
vscode.window.showInformationMessage(`Archify: published ${version.major}.${version.minor} "${version.name}" of ${space.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stop(arg: unknown): Promise<void> {
|
||||||
|
const space = await this.pickSpace(arg);
|
||||||
|
if (!space) return;
|
||||||
|
const ok = await vscode.window.showWarningMessage(
|
||||||
|
`Stop syncing ${space.name} here?`,
|
||||||
|
{ modal: true, detail: `The files stay as they are. ${LINK_FILE} is removed, and edits here no longer go to the others. The model stays on the server.` },
|
||||||
|
'Stop syncing',
|
||||||
|
);
|
||||||
|
if (ok !== 'Stop syncing') return;
|
||||||
|
this.close(space.root, true);
|
||||||
|
await vscode.workspace.fs.delete(vscode.Uri.joinPath(space.root, LINK_FILE)).then(undefined, () => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async menu(): Promise<void> {
|
||||||
|
const spaces = [...this.spaces.values()];
|
||||||
|
const signedOut = spaces.find((s) => s.state === 'signed-out');
|
||||||
|
const items: (vscode.QuickPickItem & { run: () => Promise<void> })[] = [];
|
||||||
|
if (signedOut) {
|
||||||
|
items.push({
|
||||||
|
label: `$(account) Sign in to ${new URL(signedOut.link.server).host}`,
|
||||||
|
run: async () => {
|
||||||
|
if (await this.checkServer(signedOut.link.server)) await this.auth.signIn(signedOut.link.server);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push(
|
||||||
|
{ label: '$(person-add) Copy invite link…', run: () => this.invite(undefined) },
|
||||||
|
{ label: '$(tag) Publish version…', run: () => this.publish(undefined) },
|
||||||
|
{ label: '$(history) Versions…', run: () => this.run('archify.sync.versions') },
|
||||||
|
{ label: '$(output) Show log', run: async () => this.log.show() },
|
||||||
|
{ label: '$(debug-disconnect) Stop syncing a model…', run: () => this.stop(undefined) },
|
||||||
|
);
|
||||||
|
const pick = await vscode.window.showQuickPick(items, { title: 'Archify Sync' });
|
||||||
|
await pick?.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
clearTimeout(this.cursorTimer);
|
||||||
|
this.bridge?.setProvider(undefined);
|
||||||
|
for (const space of this.spaces.values()) space.dispose();
|
||||||
|
this.spaces.clear();
|
||||||
|
for (const d of this.disposables) d.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The file behind the active tab when it is a custom editor (the Archify pages). */
|
||||||
|
function activeTabUri(): vscode.Uri | undefined {
|
||||||
|
const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input;
|
||||||
|
return input instanceof vscode.TabInputCustom || input instanceof vscode.TabInputText ? input.uri : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SyncApiError };
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
// What the model page shows of a shared model. Lives in the main bundle; the
|
||||||
|
// sync bundle plugs in as the provider once it loads. Without it, the model
|
||||||
|
// page has no Live section and nothing else changes.
|
||||||
|
|
||||||
|
export interface LivePerson {
|
||||||
|
name: string;
|
||||||
|
/** Model-relative path of the file the person has open. */
|
||||||
|
file?: string;
|
||||||
|
me?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveVersion {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
kind: 'snapshot' | 'publish' | 'restore';
|
||||||
|
name?: string;
|
||||||
|
/** ISO time. */
|
||||||
|
at: string;
|
||||||
|
restoredFrom?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LivePanel {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
state: 'connecting' | 'online' | 'offline' | 'signed-out' | 'forbidden' | 'outdated';
|
||||||
|
people: LivePerson[];
|
||||||
|
versions?: LiveVersion[];
|
||||||
|
versionsProblem?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Someone else's cursor in a file, as offsets into its "\n" text. */
|
||||||
|
export interface LiveCursor {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
anchor: number;
|
||||||
|
head: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A selection as offsets into a file's "\n" text. */
|
||||||
|
export interface LiveSelection {
|
||||||
|
anchor: number;
|
||||||
|
head: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LiveAction =
|
||||||
|
| { kind: 'publish' }
|
||||||
|
| { kind: 'invite' }
|
||||||
|
| { kind: 'signIn' }
|
||||||
|
| { kind: 'versions' }
|
||||||
|
| { kind: 'compare'; version: number }
|
||||||
|
| { kind: 'restore'; version: number };
|
||||||
|
|
||||||
|
export interface LiveProvider {
|
||||||
|
panel(root: vscode.Uri): Promise<LivePanel | undefined>;
|
||||||
|
act(root: vscode.Uri, action: LiveAction): Promise<void>;
|
||||||
|
/** Others' cursors in a shared file; empty when the file is not shared. */
|
||||||
|
cursors(file: vscode.Uri): LiveCursor[];
|
||||||
|
/** This person's cursor in a page's Source view; undefined when it closes. */
|
||||||
|
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveBridge implements vscode.Disposable {
|
||||||
|
private provider: LiveProvider | undefined;
|
||||||
|
private readonly emitter = new vscode.EventEmitter<vscode.Uri | undefined>();
|
||||||
|
/** A model root whose Live section changed; undefined for all of them. */
|
||||||
|
readonly onDidChange = this.emitter.event;
|
||||||
|
private readonly cursorEmitter = new vscode.EventEmitter<void>();
|
||||||
|
/** Someone's cursor moved somewhere; ask `cursors` for the file you show. */
|
||||||
|
readonly onDidChangeCursors = this.cursorEmitter.event;
|
||||||
|
|
||||||
|
setProvider(provider: LiveProvider | undefined): void {
|
||||||
|
this.provider = provider;
|
||||||
|
this.emitter.fire(undefined);
|
||||||
|
this.cursorEmitter.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
changed(root?: vscode.Uri): void {
|
||||||
|
this.emitter.fire(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
async panel(root: vscode.Uri): Promise<LivePanel | undefined> {
|
||||||
|
return this.provider?.panel(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
async act(root: vscode.Uri, action: LiveAction): Promise<void> {
|
||||||
|
await this.provider?.act(root, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorsChanged(): void {
|
||||||
|
this.cursorEmitter.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
cursors(file: vscode.Uri): LiveCursor[] {
|
||||||
|
return this.provider?.cursors(file) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void {
|
||||||
|
this.provider?.setCursor(file, selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.emitter.dispose();
|
||||||
|
this.cursorEmitter.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const liveBridge = new LiveBridge();
|
||||||
|
export type { LiveBridge };
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import * as path from 'node:path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { Awareness } from 'y-protocols/awareness';
|
||||||
|
import { syncSocketUrl } from './api';
|
||||||
|
import { SyncAuth } from './auth';
|
||||||
|
import { ConnectionState, LiveConnection } from './connection';
|
||||||
|
import { FileBridge } from './fileBridge';
|
||||||
|
import { OfflineStore } from './offlineStore';
|
||||||
|
import { colorFor } from './cursors';
|
||||||
|
import type { LiveCursor, LiveSelection } from './liveBridge';
|
||||||
|
import { Link } from './protocol';
|
||||||
|
|
||||||
|
/** How a space starts: as found in the workspace, just shared from here, or just joined. */
|
||||||
|
export type StartMode = 'open' | 'share' | 'join';
|
||||||
|
|
||||||
|
/** Edits loaded from the offline copy; not stored again. */
|
||||||
|
const FROM_COPY = Symbol('offline copy');
|
||||||
|
const COMPACT_AFTER = 200;
|
||||||
|
|
||||||
|
export interface Presence {
|
||||||
|
user: { id: string; name: string };
|
||||||
|
/** Model-relative path of the file the person has in front of them. */
|
||||||
|
file?: string;
|
||||||
|
/** Where the person's cursor is: Yjs relative positions, so it stays put while others type. */
|
||||||
|
cursor?: { file: string; anchor: unknown; head: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared model in this window: the Yjs document, its offline copy, the
|
||||||
|
* connection to the server, the bridge to the files, and presence.
|
||||||
|
*/
|
||||||
|
export class LiveSpace implements vscode.Disposable {
|
||||||
|
readonly doc = new Y.Doc();
|
||||||
|
readonly awareness = new Awareness(this.doc);
|
||||||
|
private readonly store: OfflineStore;
|
||||||
|
private readonly local = { origin: 'local' };
|
||||||
|
private readonly bridge: FileBridge;
|
||||||
|
private connection?: LiveConnection;
|
||||||
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
|
private hadCopy = false;
|
||||||
|
private firstSync = true;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly root: vscode.Uri,
|
||||||
|
readonly link: Link,
|
||||||
|
private readonly mode: StartMode,
|
||||||
|
private readonly services: {
|
||||||
|
context: vscode.ExtensionContext;
|
||||||
|
auth: SyncAuth;
|
||||||
|
log: vscode.LogOutputChannel;
|
||||||
|
onChange: () => void;
|
||||||
|
/** Someone's cursor moved. */
|
||||||
|
onCursors?: () => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const dir = path.join(services.context.globalStorageUri.fsPath, 'sync');
|
||||||
|
this.store = new OfflineStore(dir, link.space);
|
||||||
|
this.bridge = new FileBridge(root, this.doc, this.local, services.log);
|
||||||
|
}
|
||||||
|
|
||||||
|
get state(): ConnectionState {
|
||||||
|
return this.connection?.state ?? 'connecting';
|
||||||
|
}
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return this.link.name ?? path.basename(this.root.fsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct people in the space, this one included. */
|
||||||
|
people(): Presence[] {
|
||||||
|
const seen = new Map<string, Presence>();
|
||||||
|
for (const state of this.awareness.getStates().values()) {
|
||||||
|
const p = state as Partial<Presence>;
|
||||||
|
if (p.user?.id) seen.set(p.user.id, { user: p.user, file: p.file });
|
||||||
|
}
|
||||||
|
return [...seen.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts this person's cursor in a shared file; false when the file is not part of this model. */
|
||||||
|
setCursor(uri: vscode.Uri, selection: LiveSelection | undefined): boolean {
|
||||||
|
const rel = this.bridge.relative(uri);
|
||||||
|
if (!rel) return false;
|
||||||
|
const current = this.awareness.getLocalState() as Presence | null;
|
||||||
|
if (!current) return true;
|
||||||
|
if (!selection) {
|
||||||
|
// Only the file the cursor is in can take it away.
|
||||||
|
if (current.cursor?.file === rel) this.awareness.setLocalStateField('cursor', undefined);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const text = this.doc.getMap<Y.Text>('files').get(rel);
|
||||||
|
if (!(text instanceof Y.Text)) return true;
|
||||||
|
const at = (offset: number) => Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(text, Math.max(0, Math.min(offset, text.length))));
|
||||||
|
this.awareness.setLocalStateField('cursor', { file: rel, anchor: at(selection.anchor), head: at(selection.head) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Other people's cursors in a file, as offsets into its text here. */
|
||||||
|
cursors(uri: vscode.Uri): LiveCursor[] {
|
||||||
|
const rel = this.bridge.relative(uri);
|
||||||
|
if (!rel) return [];
|
||||||
|
const found: LiveCursor[] = [];
|
||||||
|
for (const [client, state] of this.awareness.getStates()) {
|
||||||
|
if (client === this.doc.clientID) continue;
|
||||||
|
const p = state as Partial<Presence>;
|
||||||
|
if (!p.user?.id || p.cursor?.file !== rel) continue;
|
||||||
|
const index = (json: unknown) => {
|
||||||
|
try {
|
||||||
|
return Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(json), this.doc)?.index;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const anchor = index(p.cursor.anchor);
|
||||||
|
const head = index(p.cursor.head);
|
||||||
|
if (anchor === undefined || head === undefined) continue;
|
||||||
|
found.push({ name: p.user.name, color: colorFor(p.user.id), anchor, head });
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const { log } = this.services;
|
||||||
|
this.hadCopy = this.store.exists();
|
||||||
|
for (const update of this.store.load()) Y.applyUpdate(this.doc, update, FROM_COPY);
|
||||||
|
// Registered before the connection's listener, so every edit is on disk before it is sent.
|
||||||
|
this.doc.on('update', (update: Uint8Array, origin: unknown) => {
|
||||||
|
if (origin === FROM_COPY) return;
|
||||||
|
this.store.append(update);
|
||||||
|
if (this.store.pending >= COMPACT_AFTER) this.compact();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.hadCopy) {
|
||||||
|
// Edits made while the extension was not running (git pull, another tool).
|
||||||
|
await this.bridge.pushAll(true);
|
||||||
|
} else if (this.mode === 'share') {
|
||||||
|
await this.bridge.pushAll(false);
|
||||||
|
}
|
||||||
|
// Without a copy, local edits wait for the server's files, so a file
|
||||||
|
// does not start twice (once here, once there) and lose one side.
|
||||||
|
this.bridge.start(this.hadCopy || this.mode === 'share');
|
||||||
|
|
||||||
|
const session = await this.services.auth.session(this.link.server);
|
||||||
|
if (session) this.awareness.setLocalState({ user: { id: session.account.id, name: session.account.display_name } } satisfies Presence);
|
||||||
|
this.disposables.push(
|
||||||
|
vscode.window.onDidChangeActiveTextEditor(() => this.updateFile()),
|
||||||
|
vscode.window.tabGroups.onDidChangeTabs(() => this.updateFile()),
|
||||||
|
{ dispose: () => this.awareness.destroy() },
|
||||||
|
);
|
||||||
|
this.updateFile();
|
||||||
|
let people = '';
|
||||||
|
this.awareness.on('change', () => {
|
||||||
|
// Cursors move all the time; the status bar and pages only care who is where.
|
||||||
|
const now = JSON.stringify(this.people());
|
||||||
|
if (now !== people) {
|
||||||
|
people = now;
|
||||||
|
this.services.onChange();
|
||||||
|
}
|
||||||
|
this.services.onCursors?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connection = new LiveConnection({
|
||||||
|
url: syncSocketUrl(this.link.server, this.link.space),
|
||||||
|
token: (force) => this.services.auth.accessToken(this.link.server, force),
|
||||||
|
doc: this.doc,
|
||||||
|
awareness: this.awareness,
|
||||||
|
onState: (state) => {
|
||||||
|
log.info(`sync: ${this.name} is ${state}`);
|
||||||
|
this.services.onChange();
|
||||||
|
},
|
||||||
|
onSynced: () => void this.synced(),
|
||||||
|
log: (message) => log.info(message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async synced(): Promise<void> {
|
||||||
|
if (!this.firstSync) return;
|
||||||
|
this.firstSync = false;
|
||||||
|
if (!this.hadCopy && this.mode !== 'share') {
|
||||||
|
// First time here: the server's files come to disk, then local-only files go up.
|
||||||
|
await this.bridge.pullAll();
|
||||||
|
this.bridge.start(true);
|
||||||
|
this.hadCopy = true;
|
||||||
|
}
|
||||||
|
this.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
private compact(): void {
|
||||||
|
try {
|
||||||
|
this.store.compact(Y.encodeStateAsUpdate(this.doc));
|
||||||
|
} catch (error) {
|
||||||
|
this.services.log.warn(`sync: could not compact the offline copy (${String(error)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFile(): void {
|
||||||
|
const current = this.awareness.getLocalState() as Presence | null;
|
||||||
|
if (!current) return;
|
||||||
|
// The text editor, or the tab of an Archify page (the model page, a diagram, a registry).
|
||||||
|
const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input;
|
||||||
|
const tab = input instanceof vscode.TabInputCustom || input instanceof vscode.TabInputText ? input.uri : undefined;
|
||||||
|
const uri = vscode.window.activeTextEditor?.document.uri ?? tab;
|
||||||
|
const file = uri ? this.bridge.relative(uri) : undefined;
|
||||||
|
if (current.file !== file) this.awareness.setLocalState({ ...current, file });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After signing in again. */
|
||||||
|
reconnect(): void {
|
||||||
|
void this.services.auth.session(this.link.server).then((session) => {
|
||||||
|
if (session && !this.awareness.getLocalState()) {
|
||||||
|
this.awareness.setLocalState({ user: { id: session.account.id, name: session.account.display_name } } satisfies Presence);
|
||||||
|
}
|
||||||
|
this.connection?.reconnectNow();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops syncing this model here and forgets the offline copy. */
|
||||||
|
forget(): void {
|
||||||
|
this.dispose();
|
||||||
|
this.store.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.connection?.dispose();
|
||||||
|
this.connection = undefined;
|
||||||
|
this.bridge.dispose();
|
||||||
|
for (const d of this.disposables.splice(0)) d.dispose();
|
||||||
|
if (this.store.pending) this.compact();
|
||||||
|
this.doc.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import * as path from 'node:path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import type { ModelService } from '../model/service';
|
||||||
|
import { log } from '../util/log';
|
||||||
|
import type { SyncFeature } from './index';
|
||||||
|
import { liveBridge } from './liveBridge';
|
||||||
|
|
||||||
|
// Kept in step with SYNC_COMMANDS in ./index (which lives in the other bundle).
|
||||||
|
const COMMANDS = [
|
||||||
|
'archify.sync.signIn',
|
||||||
|
'archify.sync.signOut',
|
||||||
|
'archify.sync.share',
|
||||||
|
'archify.sync.invite',
|
||||||
|
'archify.sync.join',
|
||||||
|
'archify.sync.publish',
|
||||||
|
'archify.sync.versions',
|
||||||
|
'archify.sync.stop',
|
||||||
|
'archify.sync.menu',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archify Sync is optional. This registers its commands and the URI handler,
|
||||||
|
* and loads dist/sync.js (Yjs, the WebSocket) only when it is needed: a model
|
||||||
|
* in the workspace is shared, someone signed in on this machine, or a sync
|
||||||
|
* command or link runs. Without that, nothing else happens.
|
||||||
|
*/
|
||||||
|
export function registerSync(context: vscode.ExtensionContext, models: ModelService): vscode.Disposable[] {
|
||||||
|
let feature: Promise<SyncFeature> | undefined;
|
||||||
|
const load = (): Promise<SyncFeature> =>
|
||||||
|
(feature ??= (async () => {
|
||||||
|
// A runtime path, so esbuild leaves the sync bundle out of this one.
|
||||||
|
const bundle = path.join(__dirname, 'sync.js');
|
||||||
|
const mod = require(bundle) as typeof import('./index');
|
||||||
|
const sync = new mod.SyncFeature(context, models, log(), liveBridge);
|
||||||
|
context.subscriptions.push(sync);
|
||||||
|
await sync.start();
|
||||||
|
return sync;
|
||||||
|
})());
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const used = context.globalState.get<boolean>('archifySync.used') === true;
|
||||||
|
const shared = used || (await vscode.workspace.findFiles('**/.archify-sync', '{**/node_modules/**,**/.git/**}', 1)).length > 0;
|
||||||
|
if (shared) load().catch((error) => log().error(`sync: did not start: ${String(error)}`));
|
||||||
|
})();
|
||||||
|
|
||||||
|
return [
|
||||||
|
liveBridge,
|
||||||
|
...COMMANDS.map((id) =>
|
||||||
|
vscode.commands.registerCommand(id, async (...args: unknown[]) => (await load()).run(id, ...args)),
|
||||||
|
),
|
||||||
|
vscode.window.registerUriHandler({ handleUri: async (uri) => (await load()).handleUri(uri) }),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offline copy of one shared model: the last compacted Yjs state and a
|
||||||
|
* log of every update since, each written before it is sent. Lets people
|
||||||
|
* keep working while the server is away and catch up afterwards.
|
||||||
|
*/
|
||||||
|
export class OfflineStore {
|
||||||
|
private readonly statePath: string;
|
||||||
|
private readonly logPath: string;
|
||||||
|
private logged = 0;
|
||||||
|
|
||||||
|
constructor(dir: string, id: string) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
this.statePath = path.join(dir, `${id}.state`);
|
||||||
|
this.logPath = path.join(dir, `${id}.log`);
|
||||||
|
}
|
||||||
|
|
||||||
|
exists(): boolean {
|
||||||
|
return fs.existsSync(this.statePath) || fs.existsSync(this.logPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates to apply in order: the state, then the log. A torn last entry is skipped. */
|
||||||
|
load(): Uint8Array[] {
|
||||||
|
const out: Uint8Array[] = [];
|
||||||
|
if (fs.existsSync(this.statePath)) out.push(new Uint8Array(fs.readFileSync(this.statePath)));
|
||||||
|
if (fs.existsSync(this.logPath)) {
|
||||||
|
const log = fs.readFileSync(this.logPath);
|
||||||
|
let at = 0;
|
||||||
|
while (at + 4 <= log.length) {
|
||||||
|
const length = log.readUInt32LE(at);
|
||||||
|
if (at + 4 + length > log.length) break;
|
||||||
|
out.push(new Uint8Array(log.subarray(at + 4, at + 4 + length)));
|
||||||
|
at += 4 + length;
|
||||||
|
this.logged++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends one update; synchronous, so it is on disk before the caller sends it. */
|
||||||
|
append(update: Uint8Array): void {
|
||||||
|
const head = Buffer.alloc(4);
|
||||||
|
head.writeUInt32LE(update.length);
|
||||||
|
fs.appendFileSync(this.logPath, Buffer.concat([head, update]));
|
||||||
|
this.logged++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates in the log since the last compaction. */
|
||||||
|
get pending(): number {
|
||||||
|
return this.logged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces state and log with one encoded state. */
|
||||||
|
compact(state: Uint8Array): void {
|
||||||
|
const tmp = `${this.statePath}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, state);
|
||||||
|
fs.renameSync(tmp, this.statePath);
|
||||||
|
fs.rmSync(this.logPath, { force: true });
|
||||||
|
this.logged = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(): void {
|
||||||
|
fs.rmSync(this.statePath, { force: true });
|
||||||
|
fs.rmSync(this.logPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// What the extension and the Archify Sync server agree on. Mirrors
|
||||||
|
// `packages/protocol` (generated from Rust) in archify-vscode-ext-sync; keep
|
||||||
|
// both in step when the protocol changes.
|
||||||
|
|
||||||
|
export const PROTOCOL_VERSION = 1;
|
||||||
|
/** Root map of a space's Yjs document: model-root-relative POSIX path → Y.Text. */
|
||||||
|
export const FILES_MAP = 'files';
|
||||||
|
/** The link file in a model root: names server and space, holds no secrets. */
|
||||||
|
export const LINK_FILE = '.archify-sync';
|
||||||
|
|
||||||
|
export type OrgRole = 'admin' | 'member';
|
||||||
|
export type SpaceRole = 'owner' | 'editor';
|
||||||
|
export type VersionKind = 'snapshot' | 'publish' | 'restore';
|
||||||
|
|
||||||
|
export interface ApiError { error: string; message: string }
|
||||||
|
export interface Account { id: string; organisation_id: string; name: string; display_name: string; role: OrgRole }
|
||||||
|
export interface TokenResponse { access_token: string; expires_in: number; refresh_token: string; account: Account }
|
||||||
|
export interface ServerInfo { name: string; version: string; protocol: number; set_up: boolean }
|
||||||
|
export interface Space { id: string; name: string; role: SpaceRole; created_at: string }
|
||||||
|
export interface Member { account_id: string; name: string; display_name: string; role: SpaceRole }
|
||||||
|
export interface Invite { token: string; url: string; expires_at: string }
|
||||||
|
export interface JoinResponse { space: Space }
|
||||||
|
export interface Version {
|
||||||
|
id: number;
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
kind: VersionKind;
|
||||||
|
name: string | null;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
restored_from: number | null;
|
||||||
|
}
|
||||||
|
export interface VersionFiles { version: Version; files: { path: string; text: string }[] }
|
||||||
|
|
||||||
|
/** The contents of `.archify-sync`. */
|
||||||
|
export interface Link {
|
||||||
|
server: string;
|
||||||
|
space: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLink(text: string): Link | undefined {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(text) as Partial<Link>;
|
||||||
|
if (typeof value.server === 'string' && typeof value.space === 'string' && /^[0-9a-f-]{36}$/i.test(value.space)) {
|
||||||
|
return { server: value.server.replace(/\/+$/, ''), space: value.space, name: typeof value.name === 'string' ? value.name : undefined };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not a link
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLink(link: Link): string {
|
||||||
|
return `${JSON.stringify({ server: link.server, space: link.space, name: link.name }, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionLabel(v: Version): string {
|
||||||
|
return `${v.major}.${v.minor}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
import { TextChange, minimalChange } from '../util/textDiff';
|
||||||
|
|
||||||
|
const isHigh = (code: number) => code >= 0xd800 && code <= 0xdbff;
|
||||||
|
const isLow = (code: number) => code >= 0xdc00 && code <= 0xdfff;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `minimalChange`, widened so it never splits a surrogate pair: Yjs would
|
||||||
|
* replace half a pair with U+FFFD, and every copy would get the broken text.
|
||||||
|
*/
|
||||||
|
export function safeChange(before: string, after: string): TextChange | undefined {
|
||||||
|
const change = minimalChange(before, after);
|
||||||
|
if (!change) return undefined;
|
||||||
|
let { from, to } = change;
|
||||||
|
let endAfter = after.length - (before.length - to);
|
||||||
|
// A common start ending in a high surrogate, or a common end starting with
|
||||||
|
// a low one, would split a pair: take the whole character into the change.
|
||||||
|
if (from > 0 && isHigh(before.charCodeAt(from - 1))) from--;
|
||||||
|
if (to < before.length && isLow(before.charCodeAt(to))) {
|
||||||
|
to++;
|
||||||
|
endAfter++;
|
||||||
|
}
|
||||||
|
return { from, to, insert: after.slice(from, endAfter) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Makes the Y.Text read `after` with one minimal edit; false when it already does. */
|
||||||
|
export function applyToYText(text: Y.Text, after: string): boolean {
|
||||||
|
const change = safeChange(text.toString(), after);
|
||||||
|
if (!change) return false;
|
||||||
|
if (change.to > change.from) text.delete(change.from, change.to - change.from);
|
||||||
|
if (change.insert) text.insert(change.from, change.insert);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { SyncApi } from './api';
|
||||||
|
import { LiveSpace } from './liveSpace';
|
||||||
|
import { Version, VersionFiles, versionLabel } from './protocol';
|
||||||
|
|
||||||
|
export const VERSION_SCHEME = 'archify-sync-version';
|
||||||
|
|
||||||
|
/** Read-only documents of a version's files: `archify-sync-version:/<space>/<version>/<path>`. */
|
||||||
|
export class VersionContent implements vscode.TextDocumentContentProvider {
|
||||||
|
private readonly cache = new Map<string, Promise<VersionFiles>>();
|
||||||
|
|
||||||
|
constructor(private readonly api: (space: string) => SyncApi | undefined) {}
|
||||||
|
|
||||||
|
files(space: string, version: number): Promise<VersionFiles> {
|
||||||
|
const key = `${space}/${version}`;
|
||||||
|
let found = this.cache.get(key);
|
||||||
|
if (!found) {
|
||||||
|
const api = this.api(space);
|
||||||
|
if (!api) return Promise.reject(new Error('This model is not shared from here.'));
|
||||||
|
found = api.versionFiles(space, version);
|
||||||
|
found.catch(() => this.cache.delete(key));
|
||||||
|
this.cache.set(key, found);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
|
||||||
|
const [, space, version, ...rest] = uri.path.split('/');
|
||||||
|
const files = await this.files(space, Number(version));
|
||||||
|
return files.files.find((f) => f.path === rest.join('/'))?.text ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(v: Version): vscode.QuickPickItem & { version: Version } {
|
||||||
|
const when = new Date(v.created_at).toLocaleString();
|
||||||
|
const kind = v.kind === 'publish' ? 'published' : v.kind === 'restore' ? `restored ${v.restored_from ?? ''}`.trim() : 'snapshot';
|
||||||
|
return {
|
||||||
|
label: `${v.kind === 'publish' ? '$(tag)' : v.kind === 'restore' ? '$(history)' : '$(circle-small)'} ${versionLabel(v)}${v.name ? ` ${v.name}` : ''}`,
|
||||||
|
description: kind,
|
||||||
|
detail: when,
|
||||||
|
version: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Versions of a space: pick one, then compare with the files here or restore it. */
|
||||||
|
export async function showVersions(space: LiveSpace, api: SyncApi, content: VersionContent): Promise<void> {
|
||||||
|
const versions = await api.versions(space.link.space);
|
||||||
|
if (!versions.length) {
|
||||||
|
vscode.window.showInformationMessage(`Archify: ${space.name} has no versions yet. One is taken on its own after a pause in editing.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = await vscode.window.showQuickPick(versions.map(describe), { title: `Archify | Versions of ${space.name}`, placeHolder: 'Pick a version' });
|
||||||
|
if (!picked) return;
|
||||||
|
const v = picked.version;
|
||||||
|
const action = await vscode.window.showQuickPick(
|
||||||
|
[
|
||||||
|
{ label: '$(diff) Compare with the files here', id: 'compare' },
|
||||||
|
{ label: '$(history) Restore this version', id: 'restore', detail: 'Brings these files back for everyone, as a new version. Nothing is lost.' },
|
||||||
|
],
|
||||||
|
{ title: `Archify | ${versionLabel(v)}${v.name ? ` ${v.name}` : ''}` },
|
||||||
|
);
|
||||||
|
if (action?.id === 'compare') await compareVersion(space, v, content);
|
||||||
|
if (action?.id === 'restore') await restoreVersion(space, api, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks, then brings back a version's files for everyone as a new version. */
|
||||||
|
export async function restoreVersion(space: LiveSpace, api: SyncApi, v: Version): Promise<boolean> {
|
||||||
|
const ok = await vscode.window.showWarningMessage(
|
||||||
|
`Restore ${versionLabel(v)} of ${space.name} for everyone?`,
|
||||||
|
{ modal: true, detail: 'The files go back to this version for everyone in the space. It becomes a new version; the history stays.' },
|
||||||
|
'Restore',
|
||||||
|
);
|
||||||
|
if (ok !== 'Restore') return false;
|
||||||
|
const restored = await api.restore(space.link.space, v.id);
|
||||||
|
vscode.window.showInformationMessage(`Archify: restored ${versionLabel(v)} as ${versionLabel(restored)}.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists the files that differ from a version and opens the diff of the one picked. */
|
||||||
|
export async function compareVersion(space: LiveSpace, v: Version, content: VersionContent): Promise<void> {
|
||||||
|
const files = await content.files(space.link.space, v.id);
|
||||||
|
const here = new Map<string, string>();
|
||||||
|
for (const [rel, text] of space.doc.getMap<{ toString(): string }>('files').entries()) here.set(rel, text.toString());
|
||||||
|
const paths = [...new Set([...files.files.map((f) => f.path), ...here.keys()])].sort();
|
||||||
|
const changed = paths
|
||||||
|
.map((p) => ({ path: p, then: files.files.find((f) => f.path === p)?.text, now: here.get(p) }))
|
||||||
|
.filter((f) => f.then !== f.now);
|
||||||
|
if (!changed.length) {
|
||||||
|
vscode.window.showInformationMessage(`Archify: the files here are the same as ${versionLabel(v)}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pick = await vscode.window.showQuickPick(
|
||||||
|
changed.map((f) => ({ label: f.path, description: f.then === undefined ? 'added since' : f.now === undefined ? 'removed since' : 'changed', path: f.path })),
|
||||||
|
{ title: `Archify | Changes since ${versionLabel(v)}`, placeHolder: `${changed.length} file(s) differ` },
|
||||||
|
);
|
||||||
|
if (!pick) return;
|
||||||
|
const left = vscode.Uri.from({ scheme: VERSION_SCHEME, path: `/${space.link.space}/${v.id}/${pick.path}` });
|
||||||
|
const right = vscode.Uri.joinPath(space.root, ...pick.path.split('/'));
|
||||||
|
await vscode.commands.executeCommand('vscode.diff', left, right, `${pick.path} (${versionLabel(v)} ↔ here)`);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { viewerTheme } from '../util/settings';
|
|||||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||||
import { graphPage, makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
|
import { graphPage, makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
|
||||||
import { graphScriptUri, sourceScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
import { graphScriptUri, sourceScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||||
|
import { liveBridge } from '../sync/liveBridge';
|
||||||
import { SourceSession } from './sourceSession';
|
import { SourceSession } from './sourceSession';
|
||||||
|
|
||||||
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||||
@@ -55,6 +56,8 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
readonly source: SourceSession | undefined;
|
readonly source: SourceSession | undefined;
|
||||||
private heldHtml: string | undefined;
|
private heldHtml: string | undefined;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
/** main.archify: the model page has its Live slot and takes `live` messages. */
|
||||||
|
private liveShown = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly context: vscode.ExtensionContext,
|
private readonly context: vscode.ExtensionContext,
|
||||||
@@ -80,6 +83,7 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
if (!shown && this.heldHtml !== undefined) {
|
if (!shown && this.heldHtml !== undefined) {
|
||||||
const html = this.heldHtml;
|
const html = this.heldHtml;
|
||||||
this.heldHtml = undefined;
|
this.heldHtml = undefined;
|
||||||
|
this.liveShown = false;
|
||||||
this.panel.webview.html = html;
|
this.panel.webview.html = html;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -115,10 +119,25 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
this.setGraph(false);
|
this.setGraph(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (message.type === 'liveReady') {
|
||||||
|
this.liveShown = true;
|
||||||
|
void this.postLive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === 'liveAction') {
|
||||||
|
if (this.root) void liveBridge.act(this.root, message.action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void handleWebviewMessage(message, this.uri, this.panel);
|
void handleWebviewMessage(message, this.uri, this.panel);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isMainFile(uri.path)) {
|
||||||
|
this.disposables.push(liveBridge.onDidChange((root) => {
|
||||||
|
if (!root || (this.root && root.toString() === this.root.toString())) void this.postLive();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (uri.scheme === 'file') {
|
if (uri.scheme === 'file') {
|
||||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||||
new vscode.RelativePattern(vscode.Uri.file(path.dirname(uri.fsPath)), path.basename(uri.fsPath)),
|
new vscode.RelativePattern(vscode.Uri.file(path.dirname(uri.fsPath)), path.basename(uri.fsPath)),
|
||||||
@@ -134,6 +153,13 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
void this.update();
|
void this.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Live section of a shared model's page; nothing when the model is not shared. */
|
||||||
|
private async postLive(): Promise<void> {
|
||||||
|
if (!this.liveShown || !this.root || this.disposed) return;
|
||||||
|
const live = await liveBridge.panel(this.root).catch(() => undefined);
|
||||||
|
if (this.liveShown) this.post({ type: 'live', live });
|
||||||
|
}
|
||||||
|
|
||||||
/** Posts to the webview; a panel closed mid-update is not an error. */
|
/** Posts to the webview; a panel closed mid-update is not an error. */
|
||||||
private post(message: unknown): void {
|
private post(message: unknown): void {
|
||||||
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
|
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
|
||||||
@@ -147,6 +173,7 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.heldHtml = undefined;
|
this.heldHtml = undefined;
|
||||||
|
this.liveShown = false;
|
||||||
this.panel.webview.html = html;
|
this.panel.webview.html = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +192,10 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
|
|
||||||
/** Switches a main.archify editor between its model page and the Graph page. */
|
/** Switches a main.archify editor between its model page and the Graph page. */
|
||||||
setGraph(on: boolean, query?: string): void {
|
setGraph(on: boolean, query?: string): void {
|
||||||
|
// Leaving Source for another page: drop what it held, or that page would
|
||||||
|
// wait for a sourceClose that never comes and show up later instead.
|
||||||
|
this.heldHtml = undefined;
|
||||||
|
this.source?.leave();
|
||||||
this.graph = on;
|
this.graph = on;
|
||||||
this.graphQuery = query;
|
this.graphQuery = query;
|
||||||
const modes = this.graphModes();
|
const modes = this.graphModes();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { EditorSchemas } from '../language/editorSchemas';
|
import { EditorSchemas } from '../language/editorSchemas';
|
||||||
import { readSchemas } from '../language/schemaCheck';
|
import { readSchemas } from '../language/schemaCheck';
|
||||||
|
import { liveBridge } from '../sync/liveBridge';
|
||||||
import { log } from '../util/log';
|
import { log } from '../util/log';
|
||||||
import { TextChange, normalizeEol, offsetOf, positionOf } from '../util/textDiff';
|
import { TextChange, normalizeEol, offsetOf, positionOf } from '../util/textDiff';
|
||||||
|
|
||||||
@@ -9,7 +10,8 @@ export type SourceMessage =
|
|||||||
| { type: 'sourceReady' }
|
| { type: 'sourceReady' }
|
||||||
| { type: 'sourceOpen' }
|
| { type: 'sourceOpen' }
|
||||||
| { type: 'sourceClose' }
|
| { type: 'sourceClose' }
|
||||||
| { type: 'sourceEdit'; base: string; text: string; changes: TextChange[] };
|
| { type: 'sourceEdit'; base: string; text: string; changes: TextChange[] }
|
||||||
|
| { type: 'sourceSelection'; anchor: number; head: number };
|
||||||
|
|
||||||
export type SourceKind = 'diagram' | 'registry' | 'decision';
|
export type SourceKind = 'diagram' | 'registry' | 'decision';
|
||||||
|
|
||||||
@@ -27,7 +29,13 @@ function schemasFor(context: vscode.ExtensionContext): EditorSchemas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isSourceMessage(message: { type?: string }): message is SourceMessage {
|
export function isSourceMessage(message: { type?: string }): message is SourceMessage {
|
||||||
return message.type === 'sourceReady' || message.type === 'sourceOpen' || message.type === 'sourceClose' || message.type === 'sourceEdit';
|
return (
|
||||||
|
message.type === 'sourceReady' ||
|
||||||
|
message.type === 'sourceOpen' ||
|
||||||
|
message.type === 'sourceClose' ||
|
||||||
|
message.type === 'sourceEdit' ||
|
||||||
|
message.type === 'sourceSelection'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +76,8 @@ export class SourceSession implements vscode.Disposable {
|
|||||||
vscode.workspace.onDidSaveTextDocument((doc) => {
|
vscode.workspace.onDidSaveTextDocument((doc) => {
|
||||||
if (same(doc.uri)) this.post({ type: 'sourceDirty', dirty: false });
|
if (same(doc.uri)) this.post({ type: 'sourceDirty', dirty: false });
|
||||||
}),
|
}),
|
||||||
|
// Teammates' cursors, when the model is shared (Archify Sync); nothing otherwise.
|
||||||
|
liveBridge.onDidChangeCursors(() => this.postCursors()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +86,11 @@ export class SourceSession implements vscode.Disposable {
|
|||||||
this.post({ type: 'setView', view: 'source' });
|
this.post({ type: 'setView', view: 'source' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The page is being replaced by another one (the Graph page): Source is no longer shown. */
|
||||||
|
leave(): void {
|
||||||
|
this.setShown(false);
|
||||||
|
}
|
||||||
|
|
||||||
/** Handles a Source message from the page; false for any other message. */
|
/** Handles a Source message from the page; false for any other message. */
|
||||||
handle(message: { type?: string }): boolean {
|
handle(message: { type?: string }): boolean {
|
||||||
if (!isSourceMessage(message)) return false;
|
if (!isSourceMessage(message)) return false;
|
||||||
@@ -89,10 +104,14 @@ export class SourceSession implements vscode.Disposable {
|
|||||||
break;
|
break;
|
||||||
case 'sourceClose':
|
case 'sourceClose':
|
||||||
this.setShown(false);
|
this.setShown(false);
|
||||||
|
liveBridge.setCursor(this.uri, undefined);
|
||||||
break;
|
break;
|
||||||
case 'sourceEdit':
|
case 'sourceEdit':
|
||||||
this.edit(message);
|
this.edit(message);
|
||||||
break;
|
break;
|
||||||
|
case 'sourceSelection':
|
||||||
|
liveBridge.setCursor(this.uri, { anchor: message.anchor, head: message.head });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -103,6 +122,17 @@ export class SourceSession implements vscode.Disposable {
|
|||||||
this.onShown(shown);
|
this.onShown(shown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private lastCursors = '[]';
|
||||||
|
|
||||||
|
private postCursors(): void {
|
||||||
|
if (!this.shown) return;
|
||||||
|
const cursors = liveBridge.cursors(this.uri);
|
||||||
|
const key = JSON.stringify(cursors);
|
||||||
|
if (key === this.lastCursors) return;
|
||||||
|
this.lastCursors = key;
|
||||||
|
this.post({ type: 'sourceCursors', cursors });
|
||||||
|
}
|
||||||
|
|
||||||
private post(message: unknown): void {
|
private post(message: unknown): void {
|
||||||
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
|
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -118,6 +148,8 @@ export class SourceSession implements vscode.Disposable {
|
|||||||
this.schemaKey = undefined;
|
this.schemaKey = undefined;
|
||||||
this.post({ type: 'sourceText', text, schema: this.schemaChange(text), checks: CHECKS[this.kind] });
|
this.post({ type: 'sourceText', text, schema: this.schemaChange(text), checks: CHECKS[this.kind] });
|
||||||
this.post({ type: 'sourceDirty', dirty: doc.isDirty });
|
this.post({ type: 'sourceDirty', dirty: doc.isDirty });
|
||||||
|
this.lastCursors = '';
|
||||||
|
this.postCursors();
|
||||||
if (withDiagnostics) await this.postDiagnostics();
|
if (withDiagnostics) await this.postDiagnostics();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +223,7 @@ export class SourceSession implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
if (this.shown) liveBridge.setCursor(this.uri, undefined);
|
||||||
for (const d of this.disposables.splice(0)) d.dispose();
|
for (const d of this.disposables.splice(0)) d.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { repoRootFor } from '../archify/runtime';
|
import { repoRootFor } from '../archify/runtime';
|
||||||
import { log } from '../util/log';
|
import { log } from '../util/log';
|
||||||
|
import type { LiveAction } from '../sync/liveBridge';
|
||||||
|
|
||||||
export type WebviewMessage =
|
export type WebviewMessage =
|
||||||
| { type: 'hash'; hash: string }
|
| { type: 'hash'; hash: string }
|
||||||
@@ -14,6 +15,8 @@ export type WebviewMessage =
|
|||||||
| { type: 'openDecision'; file: string }
|
| { type: 'openDecision'; file: string }
|
||||||
| { type: 'showGraph'; query?: string }
|
| { type: 'showGraph'; query?: string }
|
||||||
| { type: 'showModel' }
|
| { type: 'showModel' }
|
||||||
|
| { type: 'liveReady' }
|
||||||
|
| { type: 'liveAction'; action: LiveAction }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
/** Column for opening files next to a webview without covering it. */
|
/** Column for opening files next to a webview without covering it. */
|
||||||
@@ -100,6 +103,8 @@ export async function handleWebviewMessage(
|
|||||||
case 'openDiagram':
|
case 'openDiagram':
|
||||||
case 'showGraph':
|
case 'showGraph':
|
||||||
case 'showModel':
|
case 'showModel':
|
||||||
|
case 'liveReady':
|
||||||
|
case 'liveAction':
|
||||||
// Handled by the view, which knows the model root.
|
// Handled by the view, which knows the model root.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-1
@@ -618,6 +618,11 @@ function bridgeScript(options: PageOptions): string {
|
|||||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(head, anchor); else document.body.appendChild(head);
|
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(head, anchor); else document.body.appendChild(head);
|
||||||
|
|
||||||
var body = el('section', 'archify-model archify-model-body');
|
var body = el('section', 'archify-model archify-model-body');
|
||||||
|
// Live: filled by the host when the model is shared (Archify Sync); hidden otherwise.
|
||||||
|
var live = el('div', 'archify-live');
|
||||||
|
live.id = 'archify-live';
|
||||||
|
live.hidden = true;
|
||||||
|
body.appendChild(live);
|
||||||
var count = model.groups.reduce(function (n, g) { return n + g.diagrams.length; }, 0);
|
var count = model.groups.reduce(function (n, g) { return n + g.diagrams.length; }, 0);
|
||||||
var diagramsPanel = panel('diagrams', 'archify-model-diagrams', 'Diagrams', count, true);
|
var diagramsPanel = panel('diagrams', 'archify-model-diagrams', 'Diagrams', count, true);
|
||||||
body.appendChild(diagramsPanel);
|
body.appendChild(diagramsPanel);
|
||||||
@@ -635,6 +640,7 @@ function bridgeScript(options: PageOptions): string {
|
|||||||
var button = el('button', 'archify-model-open');
|
var button = el('button', 'archify-model-open');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.title = diagram.path;
|
button.title = diagram.path;
|
||||||
|
button.setAttribute('data-path', diagram.path);
|
||||||
button.appendChild(el('span', 'archify-model-item-title', diagram.title || diagram.path.split('/').pop()));
|
button.appendChild(el('span', 'archify-model-item-title', diagram.title || diagram.path.split('/').pop()));
|
||||||
button.appendChild(el('span', 'archify-model-item-meta', diagram.parseError ? 'does not parse: ' + diagram.parseError : (diagram.kind || 'diagram') + ' · ' + diagram.path));
|
button.appendChild(el('span', 'archify-model-item-meta', diagram.parseError ? 'does not parse: ' + diagram.parseError : (diagram.kind || 'diagram') + ' · ' + diagram.path));
|
||||||
button.addEventListener('click', function () { post({ type: 'openDiagram', path: diagram.path }); });
|
button.addEventListener('click', function () { post({ type: 'openDiagram', path: diagram.path }); });
|
||||||
@@ -677,6 +683,99 @@ function bridgeScript(options: PageOptions): string {
|
|||||||
decisionsPanel.appendChild(decisionList);
|
decisionsPanel.appendChild(decisionList);
|
||||||
}
|
}
|
||||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
||||||
|
post({ type: 'liveReady' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var LIVE_STATE = {
|
||||||
|
online: 'Live',
|
||||||
|
connecting: 'Connecting\u2026',
|
||||||
|
offline: 'Offline \u00b7 edits are kept here and sent when the server is back',
|
||||||
|
'signed-out': 'Signed out \u00b7 edits are kept here until you sign in',
|
||||||
|
forbidden: 'No access \u00b7 you are no longer a member of this model',
|
||||||
|
outdated: 'The server needs a newer extension',
|
||||||
|
};
|
||||||
|
var KIND_LABEL = { snapshot: 'snapshot', publish: 'published', restore: 'restored' };
|
||||||
|
|
||||||
|
function liveButton(label, title, action) {
|
||||||
|
var button = el('button', 'archify-model-used-link archify-live-action', label);
|
||||||
|
button.type = 'button';
|
||||||
|
button.title = title;
|
||||||
|
button.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); post({ type: 'liveAction', action: action }); });
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function when(iso) {
|
||||||
|
var date = new Date(iso);
|
||||||
|
if (isNaN(date.getTime())) return '';
|
||||||
|
var seconds = (Date.now() - date.getTime()) / 1000;
|
||||||
|
if (seconds < 60) return 'just now';
|
||||||
|
if (seconds < 3600) return Math.floor(seconds / 60) + ' min ago';
|
||||||
|
if (seconds < 86400) return Math.floor(seconds / 3600) + ' h ago';
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Live section of a shared model: who is here, the state, and versions.
|
||||||
|
function renderLive(info) {
|
||||||
|
var holder = document.getElementById('archify-live');
|
||||||
|
if (!holder) return;
|
||||||
|
holder.textContent = '';
|
||||||
|
document.querySelectorAll('.archify-live-here').forEach(function (n) { n.remove(); });
|
||||||
|
if (!info) { holder.hidden = true; return; }
|
||||||
|
holder.hidden = false;
|
||||||
|
var others = info.people.filter(function (p) { return !p.me; });
|
||||||
|
var livePanel = panel('live', 'archify-model-live is-' + info.state, 'Shared \u00b7 ' + info.name, others.length ? others.length + 1 + ' here' : info.host, true);
|
||||||
|
var status = el('div', 'archify-live-status');
|
||||||
|
status.appendChild(el('span', 'archify-live-dot'));
|
||||||
|
status.appendChild(el('span', 'archify-live-state', (LIVE_STATE[info.state] || info.state) + (info.state === 'online' ? ' on ' + info.host : '')));
|
||||||
|
if (info.state === 'signed-out') status.appendChild(liveButton('Sign in', 'Sign in to ' + info.host, { kind: 'signIn' }));
|
||||||
|
var actions = el('span', 'archify-live-actions');
|
||||||
|
actions.appendChild(liveButton('Publish version', 'Name the current state as a version', { kind: 'publish' }));
|
||||||
|
actions.appendChild(liveButton('Copy invite link', 'Copy a link that lets someone join this model', { kind: 'invite' }));
|
||||||
|
status.appendChild(actions);
|
||||||
|
livePanel.appendChild(status);
|
||||||
|
|
||||||
|
var people = el('ul', 'archify-live-people');
|
||||||
|
info.people.forEach(function (person) {
|
||||||
|
var item = el('li', 'archify-live-person' + (person.me ? ' is-me' : ''));
|
||||||
|
item.appendChild(el('strong', '', person.me ? person.name + ' (you)' : person.name));
|
||||||
|
if (person.file) item.appendChild(el('small', '', person.file));
|
||||||
|
people.appendChild(item);
|
||||||
|
});
|
||||||
|
livePanel.appendChild(people);
|
||||||
|
|
||||||
|
// Who is in which diagram, on the diagram rows.
|
||||||
|
others.forEach(function (person) {
|
||||||
|
if (!person.file) return;
|
||||||
|
document.querySelectorAll('.archify-model-open[data-path]').forEach(function (button) {
|
||||||
|
if (button.getAttribute('data-path') !== person.file) return;
|
||||||
|
var meta = button.querySelector('.archify-live-here');
|
||||||
|
if (!meta) { meta = el('span', 'archify-live-here', ''); button.appendChild(meta); }
|
||||||
|
meta.textContent = (meta.textContent ? meta.textContent.replace(/ here$/, '') + ', ' : '') + person.name + ' here';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var versions = info.versions || [];
|
||||||
|
var head = el('div', 'archify-live-versions-head');
|
||||||
|
head.appendChild(el('span', 'archify-model-summary-title', 'Versions'));
|
||||||
|
if (versions.length > 8) head.appendChild(liveButton('All ' + versions.length + '\u2026', 'Pick from every version', { kind: 'versions' }));
|
||||||
|
livePanel.appendChild(head);
|
||||||
|
if (info.versionsProblem) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', info.versionsProblem));
|
||||||
|
else if (!versions.length) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', 'No versions yet. One is taken on its own after a pause in editing.'));
|
||||||
|
var list = el('ul', 'archify-live-versions');
|
||||||
|
versions.slice(0, 8).forEach(function (v) {
|
||||||
|
var item = el('li', 'archify-live-version is-' + v.kind);
|
||||||
|
item.appendChild(el('strong', 'archify-live-version-label', v.label));
|
||||||
|
item.appendChild(el('span', 'archify-decision-status archify-live-kind', KIND_LABEL[v.kind] + (v.restoredFrom ? ' ' + v.restoredFrom : '')));
|
||||||
|
if (v.name) item.appendChild(el('span', 'archify-live-version-name', v.name));
|
||||||
|
item.appendChild(el('small', 'archify-live-version-when', when(v.at)));
|
||||||
|
var buttons = el('span', 'archify-live-actions');
|
||||||
|
buttons.appendChild(liveButton('Compare', 'Compare ' + v.label + ' with the files here', { kind: 'compare', version: v.id }));
|
||||||
|
buttons.appendChild(liveButton('Restore', 'Bring back ' + v.label + ' for everyone, as a new version', { kind: 'restore', version: v.id }));
|
||||||
|
item.appendChild(buttons);
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
livePanel.appendChild(list);
|
||||||
|
holder.appendChild(livePanel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the page shows: Diagram | Graph | Source (Model | Graph | Source on
|
// What the page shows: Diagram | Graph | Source (Model | Graph | Source on
|
||||||
@@ -704,7 +803,12 @@ function bridgeScript(options: PageOptions): string {
|
|||||||
}
|
}
|
||||||
var name = (src && src.left) || sw.left;
|
var name = (src && src.left) || sw.left;
|
||||||
var left = segment(name, name === 'View' ? 'Show the page' : 'Show the ' + name.toLowerCase(), function () { setSource(false); });
|
var left = segment(name, name === 'View' ? 'Show the page' : 'Show the ' + name.toLowerCase(), function () { setSource(false); });
|
||||||
if (sw) segment('Graph', 'Open the model as one graph, with a query box', function () { post({ type: 'showGraph', query: sw.query }); });
|
if (sw) segment('Graph', 'Open the model as one graph, with a query box', function () {
|
||||||
|
// On main.archify the Graph page replaces this one; coming back must
|
||||||
|
// show the model, not Source. Other pages open the graph beside them.
|
||||||
|
if (sourceOn && config.model) vscode.setState(Object.assign({}, vscode.getState() || {}, { sourceView: false }));
|
||||||
|
post({ type: 'showGraph', query: sw.query });
|
||||||
|
});
|
||||||
var source = src ? segment('Source', 'Edit the YAML of this file, checked as you type', function () { setSource(true); }) : null;
|
var source = src ? segment('Source', 'Edit the YAML of this file, checked as you type', function () { setSource(true); }) : null;
|
||||||
if (source) {
|
if (source) {
|
||||||
var badge = el('span', 'archify-source-badge');
|
var badge = el('span', 'archify-source-badge');
|
||||||
@@ -770,6 +874,7 @@ function bridgeScript(options: PageOptions): string {
|
|||||||
if (message.type === 'notice') showNotice(message.notice);
|
if (message.type === 'notice') showNotice(message.notice);
|
||||||
if (message.type === 'sourceDiagnostics') showProblemCount(message.count);
|
if (message.type === 'sourceDiagnostics') showProblemCount(message.count);
|
||||||
if (message.type === 'setView') setSource(message.view === 'source');
|
if (message.type === 'setView') setSource(message.view === 'source');
|
||||||
|
if (message.type === 'live') renderLive(message.live);
|
||||||
});
|
});
|
||||||
function onReady(fn) {
|
function onReady(fn) {
|
||||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
||||||
@@ -849,6 +954,26 @@ const MODEL_STYLE = `
|
|||||||
.archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
.archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
||||||
.archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; }
|
.archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; }
|
||||||
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||||
|
/* Live: a shared model's state, people and versions. */
|
||||||
|
.archify-live-status { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0 0.9rem; font-size: 0.8125rem; }
|
||||||
|
.archify-live-dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--m-muted); flex: none; }
|
||||||
|
.archify-model-live.is-online .archify-live-dot { background: var(--vscode-testing-iconPassed, #3fb950); }
|
||||||
|
.archify-model-live.is-signed-out .archify-live-dot, .archify-model-live.is-forbidden .archify-live-dot, .archify-model-live.is-outdated .archify-live-dot { background: var(--vscode-editorWarning-foreground, #d29922); }
|
||||||
|
.archify-live-actions { display: inline-flex; flex-wrap: wrap; gap: 0.35rem; margin-left: auto; }
|
||||||
|
.archify-live .archify-live-action { font-family: var(--vscode-font-family, system-ui, sans-serif); }
|
||||||
|
.archify-live-people { list-style: none; display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.6rem 0.9rem 0; padding: 0; }
|
||||||
|
.archify-live-person { display: inline-flex; align-items: baseline; gap: 0.4rem; padding: 0.2rem 0.6rem; border: 1px solid var(--m-border); border-radius: 999px; font-size: 0.8125rem; }
|
||||||
|
.archify-live-person small { color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; font-size: 0.6875rem; }
|
||||||
|
.archify-live-person.is-me { opacity: 0.75; }
|
||||||
|
.archify-live-here { font-size: 0.6875rem; color: var(--m-accent); font-weight: 600; }
|
||||||
|
.archify-live-versions-head { display: flex; align-items: center; gap: 0.5rem; margin: 0.9rem 0.9rem 0.35rem; font-size: 0.8125rem; font-weight: 600; }
|
||||||
|
.archify-live-note { margin: 0 0.9rem; font-size: 0.8125rem; }
|
||||||
|
.archify-live-versions { list-style: none; margin: 0 0.9rem; padding: 0; display: grid; gap: 0.3rem; }
|
||||||
|
.archify-live-version { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding: 0.4rem 0.7rem; border: 1px solid var(--m-border); border-radius: 0.5rem; font-size: 0.8125rem; }
|
||||||
|
.archify-live-version-label { font-family: ui-monospace, Menlo, monospace; }
|
||||||
|
.archify-live-version.is-publish .archify-live-version-label { color: var(--m-accent); }
|
||||||
|
.archify-live-version-name { font-weight: 600; }
|
||||||
|
.archify-live-version-when { color: var(--m-muted); }
|
||||||
/* Decisions: rows, chips and status badges. */
|
/* Decisions: rows, chips and status badges. */
|
||||||
.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; }
|
.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; }
|
||||||
.archify-decision-open { padding: 0.55rem 0.85rem; }
|
.archify-decision-open { padding: 0.55rem 0.85rem; }
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirro
|
|||||||
import { yaml, yamlLanguage } from '@codemirror/lang-yaml';
|
import { yaml, yamlLanguage } from '@codemirror/lang-yaml';
|
||||||
import { HighlightStyle, bracketMatching, foldGutter, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language';
|
import { HighlightStyle, bracketMatching, foldGutter, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language';
|
||||||
import { lintGutter, lintKeymap, nextDiagnostic, setDiagnostics } from '@codemirror/lint';
|
import { lintGutter, lintKeymap, nextDiagnostic, setDiagnostics } from '@codemirror/lint';
|
||||||
import { Annotation, EditorState, Transaction } from '@codemirror/state';
|
import { Annotation, EditorState, Range, StateEffect, StateField, Transaction } from '@codemirror/state';
|
||||||
import {
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
EditorView,
|
EditorView,
|
||||||
|
WidgetType,
|
||||||
drawSelection,
|
drawSelection,
|
||||||
dropCursor,
|
dropCursor,
|
||||||
highlightActiveLine,
|
highlightActiveLine,
|
||||||
@@ -30,6 +33,72 @@ export interface SourceDiagnostic {
|
|||||||
source?: string;
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A teammate's cursor (Archify Sync), as offsets into the editor's text. */
|
||||||
|
export interface RemoteCursor {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
anchor: number;
|
||||||
|
head: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setRemoteCursors = StateEffect.define<RemoteCursor[]>();
|
||||||
|
|
||||||
|
class RemoteCaret extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly name: string,
|
||||||
|
readonly color: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: RemoteCaret): boolean {
|
||||||
|
return other.name === this.name && other.color === this.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const caret = document.createElement('span');
|
||||||
|
caret.className = 'cm-remote-caret';
|
||||||
|
caret.style.borderLeftColor = this.color;
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'cm-remote-label';
|
||||||
|
label.textContent = this.name;
|
||||||
|
label.style.backgroundColor = this.color;
|
||||||
|
caret.appendChild(label);
|
||||||
|
return caret;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteDecorations(cursors: RemoteCursor[], length: number): DecorationSet {
|
||||||
|
const ranges: Range<Decoration>[] = [];
|
||||||
|
const clamp = (n: number) => Math.max(0, Math.min(n, length));
|
||||||
|
for (const c of cursors) {
|
||||||
|
const anchor = clamp(c.anchor);
|
||||||
|
const head = clamp(c.head);
|
||||||
|
if (anchor !== head) {
|
||||||
|
ranges.push(
|
||||||
|
Decoration.mark({ class: 'cm-remote-selection', attributes: { style: `background-color: color-mix(in srgb, ${c.color} 22%, transparent)` } }).range(Math.min(anchor, head), Math.max(anchor, head)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ranges.push(Decoration.widget({ widget: new RemoteCaret(c.name, c.color), side: 1 }).range(head));
|
||||||
|
}
|
||||||
|
return Decoration.set(ranges, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Teammates' carets and selections; they move with the text as anyone types. */
|
||||||
|
const remoteCursors = StateField.define<DecorationSet>({
|
||||||
|
create: () => Decoration.none,
|
||||||
|
update(decorations, tr) {
|
||||||
|
decorations = decorations.map(tr.changes);
|
||||||
|
for (const effect of tr.effects) if (effect.is(setRemoteCursors)) decorations = remoteDecorations(effect.value, tr.state.doc.length);
|
||||||
|
return decorations;
|
||||||
|
},
|
||||||
|
provide: (field) => EditorView.decorations.from(field),
|
||||||
|
});
|
||||||
|
|
||||||
/** Marks transactions that carry text from the host, so they are not sent back. */
|
/** Marks transactions that carry text from the host, so they are not sent back. */
|
||||||
const fromHost = Annotation.define<boolean>();
|
const fromHost = Annotation.define<boolean>();
|
||||||
|
|
||||||
@@ -56,6 +125,22 @@ const theme = EditorView.theme({
|
|||||||
'.cm-tooltip code': { fontFamily: 'var(--vscode-editor-font-family, ui-monospace, Menlo, monospace)', color: 'var(--frontend-stroke, var(--vscode-textPreformat-foreground))' },
|
'.cm-tooltip code': { fontFamily: 'var(--vscode-editor-font-family, ui-monospace, Menlo, monospace)', color: 'var(--frontend-stroke, var(--vscode-textPreformat-foreground))' },
|
||||||
'.cm-tooltip-autocomplete ul li[aria-selected]': { backgroundColor: 'var(--frontend-stroke, var(--vscode-list-activeSelectionBackground))', color: 'var(--bg, var(--vscode-list-activeSelectionForeground))' },
|
'.cm-tooltip-autocomplete ul li[aria-selected]': { backgroundColor: 'var(--frontend-stroke, var(--vscode-list-activeSelectionBackground))', color: 'var(--bg, var(--vscode-list-activeSelectionForeground))' },
|
||||||
'.cm-diagnostic': { padding: '4px 8px' },
|
'.cm-diagnostic': { padding: '4px 8px' },
|
||||||
|
'.cm-remote-caret': { position: 'relative', borderLeft: '2px solid', marginLeft: '-1px', marginRight: '-1px', pointerEvents: 'none' },
|
||||||
|
'.cm-remote-label': {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '100%',
|
||||||
|
left: '-2px',
|
||||||
|
padding: '0 4px',
|
||||||
|
borderRadius: '3px 3px 3px 0',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '10px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
fontFamily: 'var(--vscode-font-family, system-ui, sans-serif)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
zIndex: '5',
|
||||||
|
opacity: '0.9',
|
||||||
|
},
|
||||||
|
'.cm-line:first-child .cm-remote-label': { bottom: 'auto', top: '100%', borderRadius: '0 3px 3px 3px' },
|
||||||
'.cm-foldPlaceholder': { backgroundColor: 'transparent', border: '1px solid var(--panel-border, var(--vscode-editorWidget-border))', color: 'inherit' },
|
'.cm-foldPlaceholder': { backgroundColor: 'transparent', border: '1px solid var(--panel-border, var(--vscode-editorWidget-border))', color: 'inherit' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +156,13 @@ export class SourceEditor {
|
|||||||
readonly view: EditorView;
|
readonly view: EditorView;
|
||||||
private schemaKey: string | undefined;
|
private schemaKey: string | undefined;
|
||||||
|
|
||||||
constructor(parent: HTMLElement, text: string, onEdit: (changes: TextChange[], text: string) => void) {
|
constructor(
|
||||||
|
parent: HTMLElement,
|
||||||
|
text: string,
|
||||||
|
onEdit: (changes: TextChange[], text: string) => void,
|
||||||
|
/** The main selection moved (or the editor got focus); offsets into the text. */
|
||||||
|
onSelection: (anchor: number, head: number) => void = () => undefined,
|
||||||
|
) {
|
||||||
this.view = new EditorView({
|
this.view = new EditorView({
|
||||||
parent,
|
parent,
|
||||||
state: EditorState.create({
|
state: EditorState.create({
|
||||||
@@ -105,7 +196,12 @@ export class SourceEditor {
|
|||||||
stateExtensions(undefined as never),
|
stateExtensions(undefined as never),
|
||||||
yamlLanguage.data.of({ autocomplete: yamlCompletion() }),
|
yamlLanguage.data.of({ autocomplete: yamlCompletion() }),
|
||||||
hoverTooltip(yamlSchemaHover()),
|
hoverTooltip(yamlSchemaHover()),
|
||||||
|
remoteCursors,
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.selectionSet || update.docChanged || (update.focusChanged && update.view.hasFocus)) {
|
||||||
|
const main = update.state.selection.main;
|
||||||
|
onSelection(main.anchor, main.head);
|
||||||
|
}
|
||||||
if (!update.docChanged || update.transactions.some((tr) => tr.annotation(fromHost))) return;
|
if (!update.docChanged || update.transactions.some((tr) => tr.annotation(fromHost))) return;
|
||||||
const changes: TextChange[] = [];
|
const changes: TextChange[] = [];
|
||||||
update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => changes.push({ from: fromA, to: toA, insert: inserted.toString() }));
|
update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => changes.push({ from: fromA, to: toA, insert: inserted.toString() }));
|
||||||
@@ -125,6 +221,11 @@ export class SourceEditor {
|
|||||||
this.view.dispatch({ changes: change, annotations: [fromHost.of(true), Transaction.addToHistory.of(false)] });
|
this.view.dispatch({ changes: change, annotations: [fromHost.of(true), Transaction.addToHistory.of(false)] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Teammates' cursors, from the host; they replace the ones shown. */
|
||||||
|
setRemoteCursors(cursors: RemoteCursor[]): void {
|
||||||
|
this.view.dispatch({ effects: setRemoteCursors.of(cursors) });
|
||||||
|
}
|
||||||
|
|
||||||
/** The schema that completion and hover read; `undefined` turns them off. */
|
/** The schema that completion and hover read; `undefined` turns them off. */
|
||||||
setSchema(key: string | undefined, schema: object | undefined): void {
|
setSchema(key: string | undefined, schema: object | undefined): void {
|
||||||
if (key === this.schemaKey) return;
|
if (key === this.schemaKey) return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ArchifyHost } from '../host';
|
import type { ArchifyHost } from '../host';
|
||||||
import { TextChange, minimalChange } from '../../util/textDiff';
|
import { TextChange, minimalChange } from '../../util/textDiff';
|
||||||
import { SourceDiagnostic, SourceEditor } from './editor';
|
import { RemoteCursor, SourceDiagnostic, SourceEditor } from './editor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Source view of an Archify page: the file's YAML in an editor, in the
|
* The Source view of an Archify page: the file's YAML in an editor, in the
|
||||||
@@ -15,7 +15,8 @@ type HostMessage =
|
|||||||
| { type: 'sourceText'; text: string; schema?: { key: string; schema: object } | null; checks: string }
|
| { type: 'sourceText'; text: string; schema?: { key: string; schema: object } | null; checks: string }
|
||||||
| { type: 'sourceDiagnostics'; items: SourceDiagnostic[]; count: number }
|
| { type: 'sourceDiagnostics'; items: SourceDiagnostic[]; count: number }
|
||||||
| { type: 'sourceSchema'; schema: { key: string; schema: object } | null }
|
| { type: 'sourceSchema'; schema: { key: string; schema: object } | null }
|
||||||
| { type: 'sourceDirty'; dirty: boolean };
|
| { type: 'sourceDirty'; dirty: boolean }
|
||||||
|
| { type: 'sourceCursors'; cursors: RemoteCursor[] };
|
||||||
|
|
||||||
const STYLE = `
|
const STYLE = `
|
||||||
html.archify-source-on .container > :not(.header):not(#archify-source),
|
html.archify-source-on .container > :not(.header):not(#archify-source),
|
||||||
@@ -54,7 +55,10 @@ class SourcePane {
|
|||||||
/** The text the host last knew the page to have; edits are sent against it. */
|
/** The text the host last knew the page to have; edits are sent against it. */
|
||||||
private text = '';
|
private text = '';
|
||||||
private diagnostics: SourceDiagnostic[] = [];
|
private diagnostics: SourceDiagnostic[] = [];
|
||||||
|
private cursors: RemoteCursor[] = [];
|
||||||
private shown = false;
|
private shown = false;
|
||||||
|
private selectionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private selection: { anchor: number; head: number } | undefined;
|
||||||
|
|
||||||
constructor(private readonly host: ArchifyHost) {
|
constructor(private readonly host: ArchifyHost) {
|
||||||
window.addEventListener('message', (event) => this.onMessage(event.data as HostMessage));
|
window.addEventListener('message', (event) => this.onMessage(event.data as HostMessage));
|
||||||
@@ -139,8 +143,14 @@ class SourcePane {
|
|||||||
this.build();
|
this.build();
|
||||||
if (!this.editor) {
|
if (!this.editor) {
|
||||||
this.text = message.text;
|
this.text = message.text;
|
||||||
this.editor = new SourceEditor(this.editorHost, message.text, (changes, text) => this.onEdit(changes, text));
|
this.editor = new SourceEditor(
|
||||||
|
this.editorHost,
|
||||||
|
message.text,
|
||||||
|
(changes, text) => this.onEdit(changes, text),
|
||||||
|
(anchor, head) => this.onSelection(anchor, head),
|
||||||
|
);
|
||||||
this.editor.setDiagnostics(this.diagnostics);
|
this.editor.setDiagnostics(this.diagnostics);
|
||||||
|
this.editor.setRemoteCursors(this.cursors);
|
||||||
this.fit();
|
this.fit();
|
||||||
if (this.shown) this.editor.focus();
|
if (this.shown) this.editor.focus();
|
||||||
} else {
|
} else {
|
||||||
@@ -162,9 +172,23 @@ class SourcePane {
|
|||||||
case 'sourceDirty':
|
case 'sourceDirty':
|
||||||
this.renderState(message.dirty);
|
this.renderState(message.dirty);
|
||||||
return;
|
return;
|
||||||
|
case 'sourceCursors':
|
||||||
|
this.cursors = message.cursors;
|
||||||
|
this.editor?.setRemoteCursors(message.cursors);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Teammates see where you are; sent at most every 80 ms, after the edit it follows. */
|
||||||
|
private onSelection(anchor: number, head: number): void {
|
||||||
|
this.selection = { anchor, head };
|
||||||
|
if (this.selectionTimer) return;
|
||||||
|
this.selectionTimer = setTimeout(() => {
|
||||||
|
this.selectionTimer = undefined;
|
||||||
|
if (this.selection && this.shown) this.host.post({ type: 'sourceSelection', ...this.selection });
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
|
||||||
private onEdit(changes: TextChange[], text: string): void {
|
private onEdit(changes: TextChange[], text: string): void {
|
||||||
const base = this.text;
|
const base = this.text;
|
||||||
this.text = text;
|
this.text = text;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { FileBridge } from '../../src/sync/fileBridge';
|
||||||
|
import { LiveSpace } from '../../src/sync/liveSpace';
|
||||||
|
import type { SyncAuth } from '../../src/sync/auth';
|
||||||
|
|
||||||
const EXTENSION_ID = 'root-at-skic.archify-vscode-ext';
|
const EXTENSION_ID = 'root-at-skic.archify-vscode-ext';
|
||||||
|
|
||||||
@@ -483,6 +487,165 @@ export async function run(): Promise<void> {
|
|||||||
await vscode.workspace.fs.delete(target);
|
await vscode.workspace.fs.delete(target);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await check('without a shared model, sync stays unloaded and its commands are there', async () => {
|
||||||
|
const commands = await vscode.commands.getCommands(true);
|
||||||
|
for (const id of ['archify.sync.signIn', 'archify.sync.share', 'archify.sync.join']) assert.ok(commands.includes(id), `${id} is registered`);
|
||||||
|
// The extension host's module cache: dist/sync.js (Yjs, the WebSocket) is not loaded.
|
||||||
|
const loaded = Object.keys(require.cache).filter((file) => file.endsWith(`${path.sep}dist${path.sep}sync.js`));
|
||||||
|
assert.deepEqual(loaded, [], 'the sync bundle is not loaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('sync file bridge: local edits reach the document, teammates\' edits reach files and editors', async () => {
|
||||||
|
const root = workspaceFile('shared');
|
||||||
|
const file = (rel: string) => vscode.Uri.joinPath(root, ...rel.split('/'));
|
||||||
|
const write = (rel: string, text: string) => vscode.workspace.fs.writeFile(file(rel), new TextEncoder().encode(text));
|
||||||
|
const read = async (rel: string) => new TextDecoder().decode(await vscode.workspace.fs.readFile(file(rel)));
|
||||||
|
const exists = (rel: string) => Promise.resolve(vscode.workspace.fs.stat(file(rel))).then(() => true, () => false);
|
||||||
|
await write('main.archify', 'model:\n id: shared\n');
|
||||||
|
await write('0100-context/a.archify', 'schema_version: 1\r\nmeta:\r\n title: A\r\n');
|
||||||
|
await write('notes.txt', 'not a model file');
|
||||||
|
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const teammate = new Y.Doc();
|
||||||
|
// The server in between: every update reaches the other side.
|
||||||
|
doc.on('update', (u: Uint8Array, origin: unknown) => origin !== 'remote' && Y.applyUpdate(teammate, u, 'remote'));
|
||||||
|
teammate.on('update', (u: Uint8Array, origin: unknown) => origin !== 'remote' && Y.applyUpdate(doc, u, 'remote'));
|
||||||
|
const quiet = { info() {}, warn() {}, error() {}, debug() {}, trace() {} } as unknown as vscode.LogOutputChannel;
|
||||||
|
const bridge = new FileBridge(root, doc, { origin: 'local' }, quiet);
|
||||||
|
const theirs = () => teammate.getMap<Y.Text>('files');
|
||||||
|
try {
|
||||||
|
await bridge.pushAll(false);
|
||||||
|
bridge.start(true);
|
||||||
|
assert.deepEqual([...theirs().keys()].sort(), ['0100-context/a.archify', 'main.archify'], 'only model files are shared');
|
||||||
|
assert.equal(theirs().get('0100-context/a.archify')!.toString(), 'schema_version: 1\nmeta:\n title: A\n', 'line endings travel as \\n');
|
||||||
|
|
||||||
|
// A local edit in an open editor.
|
||||||
|
const main = await vscode.workspace.openTextDocument(file('main.archify'));
|
||||||
|
const edit = new vscode.WorkspaceEdit();
|
||||||
|
edit.insert(main.uri, new vscode.Position(2, 0), ' name: Shared\n');
|
||||||
|
assert.ok(await vscode.workspace.applyEdit(edit));
|
||||||
|
await waitFor('local edit at the teammate', () => theirs().get('main.archify')!.toString() === 'model:\n id: shared\n name: Shared\n');
|
||||||
|
await main.save();
|
||||||
|
|
||||||
|
// The teammate types twice in a row into the open file: both land, nothing is undone by an echo.
|
||||||
|
theirs().get('main.archify')!.insert(0, '# one\n');
|
||||||
|
theirs().get('main.archify')!.insert(0, '# two\n');
|
||||||
|
await waitFor('teammate edits in the open editor', () => main.getText() === '# two\n# one\nmodel:\n id: shared\n name: Shared\n');
|
||||||
|
await waitFor('saved, so git sees it', () => !main.isDirty);
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
assert.equal(theirs().get('main.archify')!.toString(), main.getText(), 'no echo changed it back');
|
||||||
|
|
||||||
|
// The teammate edits a closed file, keeping its CRLF on disk.
|
||||||
|
const a = theirs().get('0100-context/a.archify')!;
|
||||||
|
a.insert(a.length, ' output: a.html\n');
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
while ((await read('0100-context/a.archify')) !== 'schema_version: 1\r\nmeta:\r\n title: A\r\n output: a.html\r\n' && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
|
||||||
|
assert.equal(await read('0100-context/a.archify'), 'schema_version: 1\r\nmeta:\r\n title: A\r\n output: a.html\r\n');
|
||||||
|
|
||||||
|
// A new file and a deletion from the teammate.
|
||||||
|
teammate.transact(() => {
|
||||||
|
const t = new Y.Text();
|
||||||
|
t.insert(0, 'schema_version: 1\n');
|
||||||
|
theirs().set('registry/people.archify-registry', t);
|
||||||
|
theirs().set('../escape.archify', new Y.Text('nope'));
|
||||||
|
});
|
||||||
|
const until = async (what: string, probe: () => Promise<boolean>) => {
|
||||||
|
const end = Date.now() + 10_000;
|
||||||
|
while (!(await probe())) {
|
||||||
|
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await until('new file on disk', () => exists('registry/people.archify-registry'));
|
||||||
|
assert.ok(!(await exists('../escape.archify')), 'a path outside the model is never written');
|
||||||
|
teammate.transact(() => theirs().delete('0100-context/a.archify'));
|
||||||
|
await until('deleted file gone', async () => !(await exists('0100-context/a.archify')));
|
||||||
|
|
||||||
|
// A change on disk by another tool (git, an agent) to a closed file.
|
||||||
|
await write('registry/people.archify-registry', 'schema_version: 1\nelements: {}\n');
|
||||||
|
await waitFor('disk change at the teammate', () => theirs().get('registry/people.archify-registry')?.toString() === 'schema_version: 1\nelements: {}\n');
|
||||||
|
} finally {
|
||||||
|
bridge.dispose();
|
||||||
|
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
|
||||||
|
await vscode.workspace.fs.delete(root, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opt-in: a running Archify Sync server, a space on it and an access token.
|
||||||
|
const syncServer = process.env.ARCHIFY_SYNC_TEST_URL;
|
||||||
|
if (syncServer) await check('two shared copies of a model stay the same through the sync server', async () => {
|
||||||
|
const space = process.env.ARCHIFY_SYNC_TEST_SPACE!;
|
||||||
|
const token = process.env.ARCHIFY_SYNC_TEST_TOKEN!;
|
||||||
|
const storage = workspaceFile('.sync-storage');
|
||||||
|
const auth = {
|
||||||
|
session: async () => ({ account: { id: 'it', display_name: 'Integration' } }),
|
||||||
|
accessToken: async () => token,
|
||||||
|
} as unknown as SyncAuth;
|
||||||
|
const quiet = { info() {}, warn() {}, error() {}, debug() {}, trace() {} } as unknown as vscode.LogOutputChannel;
|
||||||
|
const services = (dir: string) => ({ context: { globalStorageUri: vscode.Uri.joinPath(storage, dir) } as vscode.ExtensionContext, auth, log: quiet, onChange() {} });
|
||||||
|
const link = { server: syncServer, space, name: 'IT' };
|
||||||
|
const mine = workspaceFile('live-mine');
|
||||||
|
const theirs = workspaceFile('live-theirs');
|
||||||
|
const read = async (root: vscode.Uri, rel: string) => {
|
||||||
|
try {
|
||||||
|
return new TextDecoder().decode(await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, ...rel.split('/'))));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const until = async (what: string, probe: () => Promise<boolean>) => {
|
||||||
|
const end = Date.now() + 15_000;
|
||||||
|
while (!(await probe())) {
|
||||||
|
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(mine, 'main.archify'), new TextEncoder().encode('model:\n id: it\n'));
|
||||||
|
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(mine, 'registry', 'people.archify-registry'), new TextEncoder().encode('schema_version: 1\n'));
|
||||||
|
await vscode.workspace.fs.createDirectory(theirs);
|
||||||
|
const a = new LiveSpace(mine, link, 'share', services('a'));
|
||||||
|
let b: LiveSpace | undefined;
|
||||||
|
try {
|
||||||
|
await a.start();
|
||||||
|
await until('shared copy online', async () => a.state === 'online');
|
||||||
|
b = new LiveSpace(theirs, link, 'join', services('b'));
|
||||||
|
await b.start();
|
||||||
|
await until('joined copy gets the files', async () => (await read(theirs, 'main.archify')) === 'model:\n id: it\n' && (await read(theirs, 'registry/people.archify-registry')) === 'schema_version: 1\n');
|
||||||
|
|
||||||
|
// Typing in one copy's editor shows up in the other copy's file.
|
||||||
|
const doc = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(mine, 'main.archify'));
|
||||||
|
const edit = new vscode.WorkspaceEdit();
|
||||||
|
edit.insert(doc.uri, new vscode.Position(2, 0), ' name: Live 🚀\n');
|
||||||
|
assert.ok(await vscode.workspace.applyEdit(edit));
|
||||||
|
await until('edit arrives in the other copy', async () => (await read(theirs, 'main.archify')) === 'model:\n id: it\n name: Live 🚀\n');
|
||||||
|
|
||||||
|
// A file written by another tool in the other copy comes back.
|
||||||
|
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(theirs, '0100-context', 'c.archify'), new TextEncoder().encode('schema_version: 1\n'));
|
||||||
|
await until('new file arrives back', async () => (await read(mine, '0100-context/c.archify')) === 'schema_version: 1\n');
|
||||||
|
await until('presence shows one person in both', async () => a.people().length === 1 && b!.people().length === 1);
|
||||||
|
|
||||||
|
// A cursor in one copy shows in the other, and stays on its word while text is typed above it.
|
||||||
|
const at = 'model:\n id: it\n name: '.length;
|
||||||
|
assert.ok(a.setCursor(vscode.Uri.joinPath(mine, 'main.archify'), { anchor: at, head: at + 4 }));
|
||||||
|
const theirsMain = vscode.Uri.joinPath(theirs, 'main.archify');
|
||||||
|
await until('cursor arrives in the other copy', async () => b!.cursors(theirsMain).length === 1);
|
||||||
|
const cursor = b.cursors(theirsMain)[0];
|
||||||
|
assert.deepEqual([cursor.name, cursor.anchor, cursor.head], ['Integration', at, at + 4]);
|
||||||
|
assert.equal(a.cursors(vscode.Uri.joinPath(mine, 'main.archify')).length, 0, 'your own cursor is not drawn for you');
|
||||||
|
const above = new vscode.WorkspaceEdit();
|
||||||
|
above.insert(theirsMain, new vscode.Position(0, 0), '# top\n');
|
||||||
|
assert.ok(await vscode.workspace.applyEdit(above));
|
||||||
|
await until('cursor moves with the text', async () => b!.cursors(theirsMain)[0]?.anchor === at + '# top\n'.length);
|
||||||
|
a.setCursor(vscode.Uri.joinPath(mine, 'main.archify'), undefined);
|
||||||
|
await until('cursor goes away', async () => b!.cursors(theirsMain).length === 0);
|
||||||
|
} finally {
|
||||||
|
a.dispose();
|
||||||
|
b?.dispose();
|
||||||
|
await vscode.commands.executeCommand('workbench.action.revertAndCloseActiveEditor');
|
||||||
|
for (const uri of [mine, theirs, storage]) await vscode.workspace.fs.delete(uri, { recursive: true }).then(undefined, () => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await check('registry and decision pages own their document: edits make it dirty and VS Code undo reverts them', async () => {
|
await check('registry and decision pages own their document: edits make it dirty and VS Code undo reverts them', async () => {
|
||||||
for (const [file, viewType] of [
|
for (const [file, viewType] of [
|
||||||
['model/registry/containers.archify-registry', 'archify.registry'],
|
['model/registry/containers.archify-registry', 'archify.registry'],
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { checkServerUrl, parseInviteLink, syncSocketUrl } from '../src/sync/api';
|
||||||
|
import { OfflineStore } from '../src/sync/offlineStore';
|
||||||
|
import { formatLink, parseLink } from '../src/sync/protocol';
|
||||||
|
import { applyToYText, safeChange } from '../src/sync/textSync';
|
||||||
|
import { applyChanges } from '../src/util/textDiff';
|
||||||
|
|
||||||
|
test('safeChange never splits a surrogate pair', () => {
|
||||||
|
const cases: [string, string][] = [
|
||||||
|
['x😀y', 'x😃y'],
|
||||||
|
['😀', '😃'],
|
||||||
|
['a😀', 'a😀b'],
|
||||||
|
['😀b', 'a😀b'],
|
||||||
|
['label: 🚀 API', 'label: 🛰 API'],
|
||||||
|
['plain', 'plainer'],
|
||||||
|
];
|
||||||
|
for (const [before, after] of cases) {
|
||||||
|
const change = safeChange(before, after)!;
|
||||||
|
assert.equal(applyChanges(before, [change]), after, `${before} → ${after}`);
|
||||||
|
for (const at of [change.from, change.to]) {
|
||||||
|
const code = before.charCodeAt(at);
|
||||||
|
assert.ok(!(code >= 0xdc00 && code <= 0xdfff), `change edge ${at} lands inside a pair in ${before}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(safeChange('same', 'same'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyToYText reaches the text and syncs between copies', () => {
|
||||||
|
const a = new Y.Doc();
|
||||||
|
const b = new Y.Doc();
|
||||||
|
a.on('update', (u: Uint8Array) => Y.applyUpdate(b, u));
|
||||||
|
const text = a.getMap<Y.Text>('files');
|
||||||
|
const t = new Y.Text();
|
||||||
|
text.set('main.archify', t);
|
||||||
|
applyToYText(t, 'model:\n id: shop\n');
|
||||||
|
applyToYText(t, '# 😀\nmodel:\n id: shop\n');
|
||||||
|
applyToYText(t, '# 😃\nmodel:\n id: shop2\n');
|
||||||
|
assert.equal(b.getMap<Y.Text>('files').get('main.archify')!.toString(), '# 😃\nmodel:\n id: shop2\n');
|
||||||
|
assert.equal(applyToYText(t, '# 😃\nmodel:\n id: shop2\n'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the offline copy keeps every update and survives a torn write', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-sync-'));
|
||||||
|
try {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const store = new OfflineStore(dir, 'space');
|
||||||
|
assert.equal(store.exists(), false);
|
||||||
|
doc.on('update', (u: Uint8Array) => store.append(u));
|
||||||
|
doc.getText('t').insert(0, 'one ');
|
||||||
|
doc.getText('t').insert(4, 'two');
|
||||||
|
assert.equal(store.pending, 2);
|
||||||
|
// A crash in the middle of the next append.
|
||||||
|
fs.appendFileSync(path.join(dir, 'space.log'), Buffer.from([200, 0, 0, 0, 1, 2]));
|
||||||
|
|
||||||
|
const again = new Y.Doc();
|
||||||
|
for (const u of new OfflineStore(dir, 'space').load()) Y.applyUpdate(again, u);
|
||||||
|
assert.equal(again.getText('t').toString(), 'one two');
|
||||||
|
|
||||||
|
store.compact(Y.encodeStateAsUpdate(doc));
|
||||||
|
assert.equal(store.pending, 0);
|
||||||
|
const compacted = new Y.Doc();
|
||||||
|
for (const u of new OfflineStore(dir, 'space').load()) Y.applyUpdate(compacted, u);
|
||||||
|
assert.equal(compacted.getText('t').toString(), 'one two');
|
||||||
|
store.remove();
|
||||||
|
assert.equal(store.exists(), false);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('server addresses: https, or http only on this machine', () => {
|
||||||
|
assert.equal(checkServerUrl('https://archify.example.com/').url, 'https://archify.example.com');
|
||||||
|
assert.equal(checkServerUrl('http://localhost:8080').url, 'http://localhost:8080');
|
||||||
|
assert.equal(checkServerUrl('http://127.0.0.1:8080/sync/').url, 'http://127.0.0.1:8080/sync');
|
||||||
|
assert.ok(checkServerUrl('http://archify.example.com').problem);
|
||||||
|
assert.ok(checkServerUrl('ftp://x').problem);
|
||||||
|
assert.ok(checkServerUrl('nonsense').problem);
|
||||||
|
assert.equal(syncSocketUrl('https://a.example', 'id'), 'wss://a.example/v1/spaces/id/sync?v=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invite links in both forms', () => {
|
||||||
|
const token = 'AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcde';
|
||||||
|
assert.deepEqual(parseInviteLink(`https://sync.example/join/${token}`), { server: 'https://sync.example', invite: token });
|
||||||
|
assert.deepEqual(parseInviteLink(`https://example.com/archify/join/${token}`), { server: 'https://example.com/archify', invite: token });
|
||||||
|
const ext = `vscode://root-at-skic.archify-vscode-ext/join?server=${encodeURIComponent('https://sync.example/')}&invite=${token}`;
|
||||||
|
assert.deepEqual(parseInviteLink(ext), { server: 'https://sync.example', invite: token });
|
||||||
|
assert.equal(parseInviteLink('https://example.com/other'), undefined);
|
||||||
|
assert.equal(parseInviteLink('not a link'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('link files round-trip and refuse junk', () => {
|
||||||
|
const link = { server: 'https://sync.example', space: '0198f3a2-1b2c-7d3e-8f40-123456789abc', name: 'Shop' };
|
||||||
|
assert.deepEqual(parseLink(formatLink(link)), link);
|
||||||
|
assert.equal(parseLink('{"server":"https://x","space":"../../etc"}'), undefined);
|
||||||
|
assert.equal(parseLink('not json'), undefined);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user