// 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(); 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 }>(); private readonly editorCursors: EditorCursors; private cursorTimer: ReturnType | 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { return configuredServer() ?? this.promptServer(); } private async promptServer(): Promise { const last = configuredServer() ?? this.context.globalState.get('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 { 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 { const server = await this.promptServer(); if (!server) return; const config = vscode.workspace.getConfiguration('archify.sync'); const inWorkspace = config.inspect('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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 }; 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('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 };