mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-26 14:32:58 +00:00
Refine P2P and manual setup workflows
This commit is contained in:
@@ -163,4 +163,16 @@ describe("createOpenRebuildUI", () => {
|
||||
expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true);
|
||||
expect(replicator.clearOnSetup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not complete Fetch when the rebuild dialogue closes without selecting a peer", async () => {
|
||||
const replicator = createReplicator();
|
||||
const session = createOpenRebuildUI({} as any)(replicator)(true);
|
||||
const modal = modalState.instances[0];
|
||||
|
||||
modal.onClosed?.();
|
||||
|
||||
await expect(session).resolves.toBe(false);
|
||||
expect(replicator.replicateFrom).not.toHaveBeenCalled();
|
||||
expect(replicator.setOnSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import type { P2PReplicatorStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
|
||||
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
interface Props {
|
||||
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
|
||||
@@ -134,15 +135,16 @@
|
||||
|
||||
{#if showBroadcastToggle}
|
||||
<div class="status-item status-action broadcast-row">
|
||||
<!-- Live-push to peers: stream this device's changes to connected peers for LiveSync -->
|
||||
<label class="broadcast-label" for="broadcast-toggle">
|
||||
Live-push to peers
|
||||
{translateMessage("Announce changes")}
|
||||
</label>
|
||||
<button
|
||||
id="broadcast-toggle"
|
||||
class="broadcast-button {isBroadcasting ? 'is-on' : 'is-off'}"
|
||||
onclick={toggleBroadcast}
|
||||
title={isBroadcasting ? 'Pushing changes to peers — click to stop' : 'Start pushing changes to peers'}
|
||||
title={isBroadcasting
|
||||
? translateMessage("Stop announcing changes")
|
||||
: translateMessage("Start announcing changes")}
|
||||
>
|
||||
{isBroadcasting ? '📡 On' : '📡 Off'}
|
||||
</button>
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { SetupManager } from "@/modules/features/SetupManager";
|
||||
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import { Menu } from "@/deps";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import {
|
||||
hasExactP2PPeer,
|
||||
togglePersistedP2PPeer,
|
||||
type PersistedP2PPeerSetting,
|
||||
} from "./p2pPeerSettings";
|
||||
|
||||
interface Props {
|
||||
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
|
||||
@@ -40,7 +47,6 @@
|
||||
// Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time.
|
||||
const readCurrentSettings = () => core.services.setting.currentSettings();
|
||||
const initialSettings = readCurrentSettings();
|
||||
let syncOnReplicationSetting = $state(initialSettings?.P2P_SyncOnReplication ?? "");
|
||||
type P2PRemoteOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -50,21 +56,7 @@
|
||||
let selectedP2PRemoteConfigurationId = $state(initialSettings?.P2P_ActiveRemoteConfigurationId ?? "");
|
||||
let selectingP2PRemote = $state(false);
|
||||
|
||||
function addToList(item: string, list: string): string {
|
||||
const items = list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e);
|
||||
if (!items.includes(item)) items.push(item);
|
||||
return items.join(",");
|
||||
}
|
||||
function removeFromList(item: string, list: string): string {
|
||||
return list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e && e !== item)
|
||||
.join(",");
|
||||
}
|
||||
let peerMenu: Menu | undefined;
|
||||
|
||||
function markCommunicating(peerId: string) {
|
||||
const expiry = Date.now() + COMMUNICATION_HOLD_MS;
|
||||
@@ -154,7 +146,6 @@
|
||||
});
|
||||
|
||||
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
|
||||
syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? "";
|
||||
refreshP2PRemoteOptions();
|
||||
});
|
||||
const unsubscribeLayoutReady = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
|
||||
@@ -174,6 +165,7 @@
|
||||
unsubscribeReplicatorProgress();
|
||||
unsubscribeSettings();
|
||||
unsubscribeLayoutReady();
|
||||
peerMenu?.hide();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -206,8 +198,6 @@
|
||||
const activated = activateP2PRemoteConfiguration(settings, id);
|
||||
return activated || settings;
|
||||
}, true);
|
||||
const latest = core.services.setting.currentSettings();
|
||||
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
|
||||
refreshP2PRemoteOptions();
|
||||
} finally {
|
||||
selectingP2PRemote = false;
|
||||
@@ -252,8 +242,6 @@
|
||||
const activated = activateP2PRemoteConfiguration(settings, id);
|
||||
return activated || settings;
|
||||
}, true);
|
||||
const latest = core.services.setting.currentSettings();
|
||||
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
|
||||
refreshP2PRemoteOptions();
|
||||
}
|
||||
|
||||
@@ -299,7 +287,6 @@
|
||||
const activated = activateP2PRemoteConfiguration(settings, selectedId);
|
||||
return activated || settings;
|
||||
}, true);
|
||||
syncOnReplicationSetting = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
|
||||
}
|
||||
|
||||
async function makeDecision(
|
||||
@@ -374,23 +361,49 @@
|
||||
return isLiveCommunicating || isHeldCommunicating;
|
||||
}
|
||||
|
||||
function isSyncTarget(peerName: string) {
|
||||
return syncOnReplicationSetting
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e)
|
||||
.includes(peerName);
|
||||
function isPersistedPeerSettingEnabled(setting: PersistedP2PPeerSetting, peerName: string) {
|
||||
const settings = core.services.setting.currentSettings();
|
||||
return hasExactP2PPeer(settings[setting] ?? "", peerName);
|
||||
}
|
||||
|
||||
async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
async function togglePersistedPeerSetting(
|
||||
peer: P2PServerInfo["knownAdvertisements"][number],
|
||||
setting: PersistedP2PPeerSetting
|
||||
) {
|
||||
if (!canEditP2PSettings()) {
|
||||
return;
|
||||
}
|
||||
const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
|
||||
const newValue = isSyncTarget(peer.name)
|
||||
? removeFromList(peer.name, currentValue)
|
||||
: addToList(peer.name, currentValue);
|
||||
await updateSelectedP2PRemote({ P2P_SyncOnReplication: newValue });
|
||||
const currentSettings = core.services.setting.currentSettings();
|
||||
await updateSelectedP2PRemote(togglePersistedP2PPeer(currentSettings, setting, peer.name));
|
||||
}
|
||||
|
||||
function openPeerMenu(event: MouseEvent, peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
peerMenu?.hide();
|
||||
peerMenu = new Menu()
|
||||
.addItem((item) => {
|
||||
item.setTitle(translateMessage("Synchronise when this device connects"))
|
||||
.setChecked(isPersistedPeerSettingEnabled("P2P_AutoSyncPeers", peer.name))
|
||||
.onClick(() => {
|
||||
void togglePersistedPeerSetting(peer, "P2P_AutoSyncPeers");
|
||||
});
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle(translateMessage("Follow whenever this device connects"))
|
||||
.setChecked(isPersistedPeerSettingEnabled("P2P_AutoWatchPeers", peer.name))
|
||||
.onClick(() => {
|
||||
void togglePersistedPeerSetting(peer, "P2P_AutoWatchPeers");
|
||||
});
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle(translateMessage("Include in the P2P synchronisation command"))
|
||||
.setChecked(isPersistedPeerSettingEnabled("P2P_SyncOnReplication", peer.name))
|
||||
.onClick(() => {
|
||||
void togglePersistedPeerSetting(peer, "P2P_SyncOnReplication");
|
||||
});
|
||||
});
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
peerMenu.showAtPosition({ x: rect.left, y: rect.bottom });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -486,35 +499,34 @@
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("More actions for ${DEVICE}", { DEVICE: peer.name })}
|
||||
aria-label={translateMessage("More actions for ${DEVICE}", {
|
||||
DEVICE: peer.name,
|
||||
})}
|
||||
disabled={!canEditP2PSettings()}
|
||||
onclick={(event) => openPeerMenu(event, peer)}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
</div>
|
||||
<div class="decision-row watch-row">
|
||||
<span class="decision-label">WATCH</span>
|
||||
<span class="decision-label">{translateMessage("Follow changes")}</span>
|
||||
<button
|
||||
class="emoji-button {isWatching(peer.peerId) ? 'is-watching' : ''}"
|
||||
title={isWatching(peer.peerId)
|
||||
? "Watching this peer \u2014 click to stop"
|
||||
: "Watch this peer's changes"}
|
||||
aria-label={isWatching(peer.peerId) ? "Stop watching" : "Watch peer"}
|
||||
? translateMessage("Stop following changes from this device")
|
||||
: translateMessage("Follow changes from this device")}
|
||||
aria-label={isWatching(peer.peerId)
|
||||
? translateMessage("Stop following changes from this device")
|
||||
: translateMessage("Follow changes from this device")}
|
||||
disabled={!canEditP2PSettings()}
|
||||
onclick={() => toggleWatch(peer.peerId)}
|
||||
>
|
||||
{isWatching(peer.peerId) ? "🔔" : "🔕"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="decision-row watch-row">
|
||||
<span class="decision-label">SYNC</span>
|
||||
<button
|
||||
class="emoji-button {isSyncTarget(peer.name) ? 'is-watching' : ''}"
|
||||
title={isSyncTarget(peer.name)
|
||||
? "Sync target \u2014 click to remove"
|
||||
: "Set as sync target"}
|
||||
aria-label={isSyncTarget(peer.name) ? "Remove sync target" : "Set sync target"}
|
||||
disabled={!canEditP2PSettings()}
|
||||
onclick={() => toggleSyncTarget(peer)}
|
||||
>
|
||||
{isSyncTarget(peer.name) ? "🔗" : "⛓️💥"}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="decision-status">
|
||||
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||
@@ -805,7 +817,7 @@
|
||||
}
|
||||
|
||||
.accepted-row {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
}
|
||||
|
||||
.decision-label {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
|
||||
export type PersistedP2PPeerSetting =
|
||||
| "P2P_AutoSyncPeers"
|
||||
| "P2P_AutoWatchPeers"
|
||||
| "P2P_SyncOnReplication";
|
||||
|
||||
function splitPeerSetting(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== "");
|
||||
}
|
||||
|
||||
export function hasExactP2PPeer(value: string, peerName: string): boolean {
|
||||
return splitPeerSetting(value).includes(peerName);
|
||||
}
|
||||
|
||||
export function toggleExactP2PPeer(value: string, peerName: string): string {
|
||||
const items = splitPeerSetting(value);
|
||||
const existingIndex = items.indexOf(peerName);
|
||||
if (existingIndex >= 0) {
|
||||
items.splice(existingIndex, 1);
|
||||
} else {
|
||||
items.push(peerName);
|
||||
}
|
||||
return [...new Set(items)].join(",");
|
||||
}
|
||||
|
||||
export function togglePersistedP2PPeer(
|
||||
settings: ObsidianLiveSyncSettings,
|
||||
setting: PersistedP2PPeerSetting,
|
||||
peerName: string
|
||||
): Partial<ObsidianLiveSyncSettings> {
|
||||
return {
|
||||
[setting]: toggleExactP2PPeer(settings[setting] ?? "", peerName),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
import {
|
||||
hasExactP2PPeer,
|
||||
toggleExactP2PPeer,
|
||||
togglePersistedP2PPeer,
|
||||
} from "./p2pPeerSettings";
|
||||
|
||||
describe("persisted P2P peer controls", () => {
|
||||
it("adds and removes only the selected peer without disturbing advanced patterns", () => {
|
||||
const original = "~^phone-,desktop";
|
||||
|
||||
const added = toggleExactP2PPeer(original, "phone-main");
|
||||
expect(added).toBe("~^phone-,desktop,phone-main");
|
||||
expect(hasExactP2PPeer(added, "phone-main")).toBe(true);
|
||||
|
||||
const removed = toggleExactP2PPeer(added, "phone-main");
|
||||
expect(removed).toBe(original);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"P2P_AutoSyncPeers",
|
||||
"P2P_AutoWatchPeers",
|
||||
"P2P_SyncOnReplication",
|
||||
] as const)("updates %s through the same profile-backed setting boundary", (setting) => {
|
||||
const settings = {
|
||||
P2P_AutoSyncPeers: "",
|
||||
P2P_AutoWatchPeers: "",
|
||||
P2P_SyncOnReplication: "",
|
||||
} as ObsidianLiveSyncSettings;
|
||||
|
||||
expect(togglePersistedP2PPeer(settings, setting, "peer-a")).toEqual({
|
||||
[setting]: "peer-a",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user