Several people edit one model at once through a server you run yourself (archify-vscode-ext-sync); the files stay ordinary files on each disk. Sign-in through the browser, offline-first with later merge, teammates' cursors in the Source view and text editors, and a Shared section on the model page with people, versions, Compare and Restore. Without a server nothing changes: the sync code is a separate bundle that loads only when a model is shared, after a sign-in, or when a sync command or invite link is used.
101 lines
5.0 KiB
TypeScript
101 lines
5.0 KiB
TypeScript
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)`);
|
|
}
|