Something went wrong. Try again.
A simple DAV sync application / cli
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670import type { ActionRecord, PublicFolder, RunRecord, SyncEvent } from "../src/shared.ts";import type { LastSeen } from "../src/sync/types.ts";import { bridge } from "./bridge.ts";import { FolderBrowser, type FolderSource, type Listing } from "./browser.ts";
type Tab = "activity" | "state" | "settings";
const $ = <T extends HTMLElement = HTMLElement>(selector: string) => document.querySelector<T>(selector)!;
/** Creates an element; strings become text, never HTML. */function h<K extends keyof HTMLElementTagNameMap>( tag: K, props: Partial<Record<string, string | boolean>> = {}, ...children: (Node | string | null)[]): HTMLElementTagNameMap[K] { const element = document.createElement(tag); for (const [key, value] of Object.entries(props)) { if (value === false || value === undefined) continue; if (key === "class") element.className = String(value); else element.setAttribute(key, value === true ? "" : value); } element.append(...children.filter((child): child is Node | string => child !== null)); return element;}
let folders: PublicFolder[] = [];/** The folder on show, if the folder view is on show. */let current: PublicFolder | null = null;let tab: Tab = "activity";let selectedRun: number | null = null;let stateOffset = 0;
// ---- Folders --------------------------------------------------------------------
async function loadFolders(): Promise<void> { folders = await bridge.invoke<PublicFolder[]>("folders.list"); if (current) current = folders.find((folder) => folder.id === current!.id) ?? null; renderFolderList();}
function renderFolderList(): void { $("#folder-list").replaceChildren( ...folders.map((folder) => { const item = h( "li", { class: folder.id === current?.id ? "selected" : "", tabindex: "0", title: `${folder.localPath} ⇄ ${folder.remoteUrl}` }, h("span", { class: `dot ${folder.enabled ? "on" : "off"}` }), h("span", { class: "name" }, folder.name), ); item.addEventListener("click", () => select(folder)); item.addEventListener("keydown", (event) => event.key === "Enter" && select(folder)); return item; }), );}
function showView(view: "empty" | "wizard" | "folder"): void { $("#empty").hidden = view !== "empty"; $("#wizard").hidden = view !== "wizard"; $("#folder-view").hidden = view !== "folder";}
/** Shows a folder, or the welcome page when there is none. */function select(folder: PublicFolder | null): void { current = folder; selectedRun = null; renderFolderList(); showView(folder ? "folder" : "empty"); if (!folder) return; $("#folder-title").textContent = folder.name; $("#folder-sub").textContent = `${folder.localPath} ⇄ ${folder.remoteUrl}`; $("#progress").textContent = ""; fillForm(folder); showTab(tab);}
// ---- Where folders are: the server's and this computer's ------------------------------------
interface Server { serverUrl: string; username: string; /** Left out: the one stored for this folder (only ever sent to the server it was stored for). */ password?: string; id?: string;}
/** Folders on a WebDAV server, below its root. `nextcloudLike` reflects the last listing. */function remoteSource(server: () => Server): FolderSource & { nextcloudLike: boolean } { const source = { nextcloudLike: false, async list(path: string) { const listing = await bridge.invoke<Listing & { nextcloudLike: boolean }>("remote.list", { server: server(), path }); source.nextcloudLike = listing.nextcloudLike; return listing; }, mkdir: (path: string, name: string) => bridge.invoke<string>("remote.mkdir", { server: server(), path, name }), crumbs: (path: string) => [{ label: hostOf(server().serverUrl), path: "" }, ...pathCrumbs(path)], child: (path: string, name: string) => (path ? `${path}/${name}` : name), }; return source;}
const localSource: FolderSource = { home: "~", async list(path: string) { const listing = await bridge.invoke<{ path: string; folders: string[] }>("fs.listDir", { path }); return { path: listing.path, folders: listing.folders.map((name) => ({ name, label: name })) }; }, mkdir: (path, name) => bridge.invoke<string>("fs.mkdir", { path, name }), crumbs: localCrumbs, child: joinPath,};
function pathCrumbs(path: string): { label: string; path: string }[] { const parts = path.split("/").filter(Boolean); return parts.map((label, index) => ({ label, path: parts.slice(0, index + 1).join("/") }));}
function localCrumbs(path: string): { label: string; path: string }[] { const windows = /^[A-Za-z]:\\/.test(path); const separator = windows ? "\\" : "/"; const parts = path.split(separator).filter(Boolean); if (windows) return parts.map((label, index) => ({ label, path: parts.slice(0, index + 1).join(separator) + (index === 0 ? separator : "") })); return [{ label: "/", path: "/" }, ...parts.map((label, index) => ({ label, path: `/${parts.slice(0, index + 1).join("/")}` }))];}
function joinPath(dir: string, name: string): string { const separator = /^[A-Za-z]:\\/.test(dir) ? "\\" : "/"; return dir.endsWith(separator) ? dir + name : dir + separator + name;}
function hostOf(url: string): string { try { const parsed = new URL(url); return parsed.host + parsed.pathname.replace(/\/$/, ""); } catch { return url; }}
/** How a folder on the server reads: host and path. */function remoteLabel(serverUrl: string, remotePath: string): string { return remotePath ? `${hostOf(serverUrl)}/${remotePath}` : hostOf(serverUrl);}
function lastName(path: string): string { return path.split(/[\\/]/).filter(Boolean).pop() ?? "";}
// ---- New folder: a wizard -----------------------------------------------------------------------// 1. the server: address and credentials// 2. the folder on the server, browsing it (which is also the connection test)// 3. the folder on this computer, and a name
type Step = "server" | "remote" | "local";const serverForm = $<HTMLFormElement>("#server-form");const localForm = $<HTMLFormElement>("#local-form");const wizard: { server: Server; remotePath: string; remote: ReturnType<typeof remoteSource> } = { server: { serverUrl: "", username: "", password: "" }, remotePath: "", remote: remoteSource((): Server => wizard.server),};const wizardRemote = new FolderBrowser($("#wizard-remote"));const wizardLocal = new FolderBrowser($("#wizard-local"));
function step(name: Step): void { const order: Step[] = ["server", "remote", "local"]; for (const element of document.querySelectorAll<HTMLElement>("#wizard .step")) element.hidden = element.dataset.step !== name; for (const item of document.querySelectorAll<HTMLElement>("#wizard .steps li")) { const at = order.indexOf(item.dataset.step as Step); item.className = at === order.indexOf(name) ? "active" : at < order.indexOf(name) ? "done" : ""; } if (name === "server") (serverForm.elements.namedItem("serverUrl") as HTMLInputElement).focus();}
function startWizard(): void { current = null; renderFolderList(); serverForm.reset(); localForm.reset(); status(serverForm, ""); status(localForm, ""); showView("wizard"); step("server");}
function status(form: HTMLElement, text: string, kind: "" | "ok" | "error" = ""): void { const element = form.querySelector<HTMLElement>(".status")!; element.textContent = text; element.className = `status ${kind}`;}
serverForm.addEventListener("submit", async (event) => { event.preventDefault(); const value = (name: string) => (serverForm.elements.namedItem(name) as HTMLInputElement).value; wizard.server = { serverUrl: value("serverUrl").trim(), username: value("username").trim(), password: value("password") }; status(serverForm, "Looking for the server's WebDAV root…"); try { // What was typed may be a bare host: discovery finds the root (and the user's files below it). const found = await bridge.invoke<{ serverUrl: string; steps: string[] }>("remote.discover", { server: wizard.server }); wizard.server = { ...wizard.server, serverUrl: found.serverUrl }; const where = $("#wizard-root"); where.textContent = found.serverUrl; where.title = found.steps.join("\n"); status(serverForm, ""); step("remote"); await wizardRemote.open("", wizard.remote); } catch (error) { status(serverForm, (error as Error).message, "error"); }});
$("#wizard-remote-next").addEventListener("click", async () => { wizard.remotePath = wizardRemote.path; $("#wizard-remote-chosen").textContent = remoteLabel(wizard.server.serverUrl, wizard.remotePath); const name = localForm.elements.namedItem("name") as HTMLInputElement; if (!name.value) name.value = lastName(wizard.remotePath) || hostOf(wizard.server.serverUrl).split("/")[0]!; step("local"); if (!wizardLocal.path) await wizardLocal.open("~", localSource);});
localForm.addEventListener("submit", async (event) => { event.preventDefault(); const value = (name: string) => (localForm.elements.namedItem(name) as HTMLInputElement).value; try { const saved = await bridge.invoke<PublicFolder>("folders.save", { name: value("name"), serverUrl: wizard.server.serverUrl, username: wizard.server.username, password: wizard.server.password, remotePath: wizard.remotePath, localPath: wizardLocal.path, interval: Number(value("interval")) * 60, enabled: true, ignore: [], // Servers that report file ids change a folder's etag with its content. trustCollectionEtags: wizard.remote.nextcloudLike, }); await loadFolders(); tab = "activity"; select(folders.find((folder) => folder.id === saved.id) ?? saved); // Show what the first sync would do before anything happens. void startSync(true); } catch (error) { status(localForm, (error as Error).message, "error"); }});
for (const button of document.querySelectorAll<HTMLButtonElement>(".wizard-back")) { button.addEventListener("click", () => step(button.closest<HTMLElement>(".step")!.dataset.step === "local" ? "remote" : "server"));}for (const button of document.querySelectorAll<HTMLButtonElement>(".wizard-cancel")) { button.addEventListener("click", () => select(folders[0] ?? null));}
// ---- Choosing a folder in a dialog (settings) -----------------------------------------------------
const browseDialog = $<HTMLDialogElement>("#browse-dialog");const dialogBrowser = new FolderBrowser($("#browse-browser"));
async function browse(title: string, source: FolderSource, start: string): Promise<string | null> { $("#browse-title").textContent = title; browseDialog.showModal(); await dialogBrowser.open(start, source); return new Promise((resolve) => { const finish = (chosen: string | null) => { browseDialog.removeEventListener("cancel", onCancel); $("#browse-choose").onclick = $("#browse-cancel").onclick = null; if (browseDialog.open) browseDialog.close(); resolve(chosen); }; const onCancel = () => finish(null); browseDialog.addEventListener("cancel", onCancel); $("#browse-cancel").onclick = () => finish(null); $("#browse-choose").onclick = () => finish(dialogBrowser.path); });}
// ---- Tabs ---------------------------------------------------------------------------
function showTab(name: Tab): void { tab = name; for (const button of document.querySelectorAll<HTMLButtonElement>(".tabs button")) { button.setAttribute("aria-selected", String(button.dataset.tab === name)); } for (const panel of document.querySelectorAll<HTMLElement>(".tab")) panel.hidden = panel.id !== `tab-${name}`; if (name === "activity") void loadRuns(); if (name === "state") void loadState(true);}
// ---- Activity -----------------------------------------------------------------------
const STATUS_LABEL: Record<string, string> = { running: "running", ok: "done", partial: "incomplete", failed: "failed", planned: "preview", skipped: "skipped",};
async function loadRuns(): Promise<void> { if (!current) return; const runs = await bridge.invoke<RunRecord[]>("runs.list", { folder: current.id }); const list = $("#run-list"); if (runs.length === 0) { list.replaceChildren(h("li", { class: "none" }, "No syncs yet.")); $("#run-detail").replaceChildren(); return; } // Runs that did nothing are folded into a count, so the list shows what happened. const items: HTMLElement[] = []; let quiet = 0; let held = 0; const flushQuiet = () => { if (quiet) { const note = held ? ` (${held} change${held === 1 ? "" : "s"} held back: the server only allows reading there)` : ""; items.push(h("li", { class: "quiet" }, `${quiet} sync${quiet === 1 ? "" : "s"} with nothing to do${note}`)); } quiet = 0; held = 0; }; for (let index = 0; index < runs.length; index++) { const run = runs[index]!; // A stretch offline (or with the keychain locked) skips sync after sync for // the same reason: one line for the stretch, showing its latest. if (run.status === "skipped" && run.id !== selectedRun) { let count = 1; while (runs[index + count]?.status === "skipped" && runs[index + count]!.error === run.error) count++; if (count > 1) { flushQuiet(); items.push( h( "li", { class: "quiet" }, h("span", { class: "badge skipped" }, "skipped"), ` ${count} syncs since ${formatTime(runs[index + count - 1]!.started)}: ${run.error ?? ""}`, ), ); index += count - 1; continue; } } // Changes held back for read-only folders recur every sync; they are not news. const empty = run.status === "ok" && Object.keys(run.summary ?? {}).every((key) => key === "held"); if (empty && run.id !== selectedRun && quiet === 0) held = run.summary?.held ?? 0; if (empty && run.id !== selectedRun) { quiet++; continue; } flushQuiet(); const item = h( "li", { class: run.id === selectedRun ? "selected" : "", tabindex: "0" }, h("span", { class: `badge ${run.status}` }, STATUS_LABEL[run.status] ?? run.status), h("time", {}, formatTime(run.started)), h("span", { class: "summary" }, run.error ?? summarize(run.summary)), ); item.addEventListener("click", () => void showRun(run)); items.push(item); } flushQuiet(); list.replaceChildren(...items); if (selectedRun === null) { const first = runs.find((run) => run.status !== "ok" || Object.keys(run.summary ?? {}).some((key) => key !== "held")); if (first) void showRun(first); }}
async function showRun(run: RunRecord): Promise<void> { selectedRun = run.id; for (const item of document.querySelectorAll("#run-list li")) item.classList.remove("selected"); const actions = await bridge.invoke<ActionRecord[]>("runs.actions", { run: run.id }); const detail = $("#run-detail"); const heading = h( "header", {}, h("strong", {}, run.status === "planned" ? "Preview" : "Sync"), ` ${formatTime(run.started)}`, run.finished ? ` · ${((run.finished - run.started) / 1000).toFixed(1)} s` : "", ); if (run.error) { detail.replaceChildren(heading, h("p", { class: "error" }, run.error)); return; } if (actions.length === 0) { detail.replaceChildren(heading, h("p", { class: "muted" }, "Both sides were already in step.")); return; } detail.replaceChildren( heading, h( "table", { class: "grid actions" }, h("thead", {}, h("tr", {}, ...["Action", "Where", "Path", "Why", "Result"].map((label) => h("th", {}, label)))), h( "tbody", {}, ...actions.map((action) => h( "tr", { class: action.status }, h("td", {}, h("span", { class: `verb ${action.type}` }, action.type)), h("td", { class: "muted" }, action.side ?? ""), h("td", { class: "path" }, action.path, action.target ? h("span", { class: "muted" }, ` → ${action.target}`) : null), h("td", {}, action.reason), h("td", {}, h("span", { class: `badge ${action.status}` }, action.status), action.detail ? h("div", { class: "muted small" }, action.detail) : null), ), ), ), ), );}
function summarize(summary: Record<string, number> | null): string { if (!summary || Object.keys(summary).length === 0) return "nothing to do"; return Object.entries(summary) .map(([type, count]) => `${count} ${type}`) .join(", ");}
// ---- Last-seen state ------------------------------------------------------------------
async function loadState(reset: boolean): Promise<void> { if (!current) return; if (reset) stateOffset = 0; const { total, entries } = await bridge.invoke<{ total: number; entries: LastSeen[] }>("state.entries", { folder: current.id, filter: $<HTMLInputElement>("#state-filter").value, offset: stateOffset, }); const table = $("#state-table"); if (reset) { table.replaceChildren( h( "thead", {}, h( "tr", {}, ...["Path", "Size", "SHA-256", "Head (4 KiB)", "Local mtime", "Local ctime", "Inode", "Remote etag", "File id", "Remote mtime", "Synced"].map((label) => h("th", {}, label), ), ), ), h("tbody"), ); } table.querySelector("tbody")!.append( ...entries.map((entry) => h( "tr", {}, h("td", { class: "path", title: `${entry.local.url}\n${entry.remote.url}` }, entry.kind === "dir" ? `${entry.path}/` : entry.path), h("td", { class: "num" }, entry.kind === "dir" ? "" : formatSize(entry.size)), hashCell(entry.sha256), hashCell(entry.head), h("td", {}, formatTime(entry.local.mtime)), h("td", {}, entry.local.ctime === null ? "" : formatTime(entry.local.ctime)), h("td", { class: "mono" }, entry.local.id ?? ""), h("td", { class: "mono", title: entry.remote.etag ?? "" }, short(entry.remote.etag ?? "", 16)), h("td", { class: "mono" }, entry.remote.id ?? ""), h("td", {}, entry.remote.mtime ? formatTime(entry.remote.mtime) : ""), h("td", {}, formatTime(entry.syncedAt)), ), ), ); stateOffset += entries.length; $("#state-count").textContent = `${total} entr${total === 1 ? "y" : "ies"}`; $("#state-more").hidden = stateOffset >= total;}
function hashCell(hash: string | null): HTMLElement { return h("td", { class: "mono", title: hash ?? "" }, hash ? short(hash, 12) : "");}
// ---- Settings ----------------------------------------------------------------------------
const form = $<HTMLFormElement>("#folder-form");const field = <T extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement>(name: string) => form.elements.namedItem(name) as T;/** Where the folder points, as edited but not yet saved. */const place = { remotePath: "", localPath: "" };
function fillForm(folder: PublicFolder): void { field("name").value = folder.name; field("serverUrl").value = folder.serverUrl; field("username").value = folder.username; field("password").value = ""; field("password").placeholder = folder.hasPassword ? "unchanged" : ""; field("interval").value = String(Math.round(folder.interval / 60)); field("enabled").checked = folder.enabled; field<HTMLTextAreaElement>("ignore").value = folder.ignore.join("\n"); field("trustCollectionEtags").checked = folder.trustCollectionEtags; place.remotePath = folder.remotePath; place.localPath = folder.localPath; renderPlaces(); setStatus("");}
function renderPlaces(): void { $("#settings-remote").textContent = remoteLabel(field("serverUrl").value, place.remotePath); $("#settings-local").textContent = place.localPath;}
function settingsServer(): Server { const password = field("password").value; return { serverUrl: field("serverUrl").value.trim(), username: field("username").value.trim(), ...(password ? { password } : {}), ...(current ? { id: current.id } : {}) };}
function formInput() { return { ...settingsServer(), name: field("name").value, remotePath: place.remotePath, localPath: place.localPath, interval: Number(field("interval").value) * 60, enabled: field("enabled").checked, ignore: field<HTMLTextAreaElement>("ignore").value.split("\n"), trustCollectionEtags: field("trustCollectionEtags").checked, };}
function setStatus(text: string, kind: "" | "ok" | "error" = ""): void { status(form, text, kind);}
field("serverUrl").addEventListener("input", renderPlaces);
$("#change-remote").addEventListener("click", async () => { const chosen = await browse("Folder on the server", remoteSource(settingsServer), place.remotePath); if (chosen === null || chosen === place.remotePath) return; place.remotePath = chosen; renderPlaces(); setStatus("Not saved yet. Saving starts this folder's sync over: both sides are compared afresh, nothing is deleted.");});
$("#change-local").addEventListener("click", async () => { const chosen = await browse("Folder on this computer", localSource, place.localPath); if (chosen === null || chosen === place.localPath) return; place.localPath = chosen; renderPlaces(); setStatus("Not saved yet. Saving starts this folder's sync over: both sides are compared afresh, nothing is deleted.");});
form.addEventListener("submit", async (event) => { event.preventDefault(); if (!current) return; try { const moved = current.remotePath !== place.remotePath || current.localPath !== place.localPath || current.serverUrl !== field("serverUrl").value.trim(); const saved = await bridge.invoke<PublicFolder>("folders.save", formInput()); await loadFolders(); select(folders.find((folder) => folder.id === saved.id) ?? saved); showTab("settings"); setStatus(moved ? "Saved. The next sync compares both sides afresh." : "Saved.", "ok"); } catch (error) { setStatus((error as Error).message, "error"); }});
$("#remove-folder").addEventListener("click", async () => { if (!current) return; const ok = confirm( `Stop syncing “${current.name}”?\n\nNo files are deleted on either side; davsync forgets the folder and its sync history.`, ); if (!ok) return; await bridge.invoke("folders.remove", { id: current.id }); current = null; await loadFolders(); select(folders[0] ?? null);});
// ---- Syncing -----------------------------------------------------------------------------------
async function startSync(dryRun: boolean): Promise<void> { if (!current) return; selectedRun = null; $("#progress").textContent = dryRun ? "Working out what to do…" : "Starting…"; await bridge.invoke("sync.start", { folder: current.id, dryRun }); if (tab !== "activity") showTab("activity");}
$("#sync-now").addEventListener("click", () => void startSync(false));$("#preview").addEventListener("click", () => void startSync(true));
let planned = 0;let performed = 0;bridge.on<SyncEvent>("sync", (event) => { if (event.folder !== current?.id) return; const progress = $("#progress"); switch (event.type) { case "phase": progress.textContent = { scanning: "Scanning both sides…", detecting: "Recognising changes…", planning: "Planning…", executing: "Syncing…" }[event.phase]; if (event.phase === "executing") performed = 0; break; case "planned": planned = event.actions; selectedRun = event.run; break; case "action": performed++; progress.textContent = `Syncing… ${performed} of ${planned}`; break; case "finished": progress.textContent = event.error ? "Failed" : event.status === "planned" ? "" : "Done"; if (event.run !== null) selectedRun = event.run; void loadRuns().then(() => { const item = document.querySelector("#run-list li.selected"); item?.dispatchEvent(new Event("click")); }); if (tab === "state") void loadState(true); break; }});
// ---- Service status ----------------------------------------------------------------------------
async function loadService(): Promise<void> { const info = await bridge.invoke<{ service: number | null; config: string }>("app.info"); const service = $("#service"); service.className = `service ${info.service ? "on" : "off"}`; service.textContent = info.service ? "Service running" : "Service not running"; service.title = info.service ? `Syncing in the background (pid ${info.service})` : "Start it with: davsync service (or at every login: davsync install)";}
// ---- Wiring ----------------------------------------------------------------------------------------
for (const button of document.querySelectorAll<HTMLButtonElement>(".tabs button")) { button.addEventListener("click", () => showTab(button.dataset.tab as Tab));}for (const id of ["#add-folder", "#add-first"]) $(id).addEventListener("click", startWizard);let filterTimer: number | undefined;$("#state-filter").addEventListener("input", () => { clearTimeout(filterTimer); filterTimer = window.setTimeout(() => void loadState(true), 200);});$("#state-more").addEventListener("click", () => void loadState(false));
// Activity from the background service shows up without asking.setInterval(() => { if (document.visibilityState !== "visible") return; void loadService(); if (current && tab === "activity") void loadRuns();}, 5000);
function formatTime(ms: number): string { const date = new Date(ms); const today = new Date().toDateString() === date.toDateString(); return today ? date.toLocaleTimeString() : date.toLocaleString();}
function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; const units = ["KiB", "MiB", "GiB", "TiB"]; let value = bytes / 1024; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;}
function short(text: string, length: number): string { return text.length > length ? `${text.slice(0, length)}…` : text;}
await Promise.all([loadFolders(), loadService()]);select(folders[0] ?? null);bridge.emit("loaded", { folders: folders.length });