A labelled Sync button sits top right on every Archify page, also in Source, and opens the Sync menu. On a shared model it turns green and reads Shared. The title-bar icon and the pre-share status bar entry are gone, with their settings.
626 lines
27 KiB
TypeScript
626 lines
27 KiB
TypeScript
// 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',
|
|
'archify.sync.setServer',
|
|
] 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;
|
|
/** Servers with a session on this machine, for the status bar before any model is shared. */
|
|
private signedIn: string[] = [];
|
|
|
|
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();
|
|
void this.refreshSignedIn();
|
|
}),
|
|
vscode.workspace.onDidChangeConfiguration((e) => e.affectsConfiguration('archify.sync') && 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')));
|
|
await this.refreshSignedIn();
|
|
}
|
|
|
|
private async refreshSignedIn(): Promise<void> {
|
|
this.signedIn = await this.auth.servers();
|
|
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);
|
|
this.bridge?.changed();
|
|
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;
|
|
case 'archify.sync.setServer':
|
|
await this.setServer();
|
|
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));
|
|
}
|
|
|
|
/** The server from the archify.sync.server setting, or asked for (and remembered). */
|
|
private async askServer(): Promise<string | undefined> {
|
|
return configuredServer() ?? this.promptServer();
|
|
}
|
|
|
|
private async promptServer(): Promise<string | undefined> {
|
|
const last = configuredServer() ?? 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;
|
|
}
|
|
|
|
/** Writes archify.sync.server: in the workspace when the workspace sets it, otherwise for the user. */
|
|
private async setServer(): Promise<void> {
|
|
const server = await this.promptServer();
|
|
if (!server) return;
|
|
const config = vscode.workspace.getConfiguration('archify.sync');
|
|
const inWorkspace = config.inspect<string>('server')?.workspaceValue !== undefined;
|
|
await config.update('server', server, inWorkspace ? vscode.ConfigurationTarget.Workspace : vscode.ConfigurationTarget.Global);
|
|
vscode.window.showInformationMessage(`Archify Sync: server set to ${new URL(server).host}${inWorkspace ? ' for this workspace' : ''}.`);
|
|
}
|
|
|
|
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 servers = await this.auth.servers();
|
|
const configured = configuredServer();
|
|
type Item = vscode.QuickPickItem & { run?: () => Promise<void> };
|
|
const items: Item[] = [];
|
|
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);
|
|
},
|
|
});
|
|
} else if (!servers.length) {
|
|
items.push({
|
|
label: '$(account) Sign in…',
|
|
description: configured ? new URL(configured).host : 'asks for the server address',
|
|
run: () => this.signInCommand(),
|
|
});
|
|
}
|
|
items.push(
|
|
{ label: '$(live-share) Share a model…', run: () => this.share(undefined) },
|
|
{ label: '$(link) Join a shared model…', description: 'with an invite link', run: () => this.join(undefined) },
|
|
);
|
|
if (spaces.length) {
|
|
items.push(
|
|
{ label: 'Shared models', kind: vscode.QuickPickItemKind.Separator },
|
|
{ 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: '$(debug-disconnect) Stop syncing a model…', run: () => this.stop(undefined) },
|
|
);
|
|
}
|
|
items.push(
|
|
{ label: 'Settings', kind: vscode.QuickPickItemKind.Separator },
|
|
{ label: '$(server) Server address…', description: configured ? new URL(configured).host : 'not set', run: () => this.setServer() },
|
|
{ label: '$(settings-gear) Open Sync settings', run: async () => void vscode.commands.executeCommand('workbench.action.openSettings', 'archify.sync') },
|
|
{ label: '$(output) Show log', run: async () => this.log.show() },
|
|
);
|
|
if (servers.length) items.push({ label: '$(sign-out) Sign out…', description: servers.map((s) => new URL(s).host).join(', '), run: () => this.signOut() });
|
|
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();
|
|
}
|
|
}
|
|
|
|
/** archify.sync.server when it holds a usable address. */
|
|
function configuredServer(): string | undefined {
|
|
const value = vscode.workspace.getConfiguration('archify.sync').get<string>('server', '').trim();
|
|
return value ? checkServerUrl(value).url : undefined;
|
|
}
|
|
|
|
/** 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 };
|