gloomberb 0.17.2 → 0.17.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/api-client/chat.ts +28 -0
- package/src/api-client/errors.ts +11 -1
- package/src/api-client/futures-curve.ts +16 -0
- package/src/api-client/index.ts +22 -9
- package/src/api-client/normalizers.ts +21 -0
- package/src/api-client/socket.ts +27 -0
- package/src/api-client/types.ts +3 -1
- package/src/app/runtime/startup.ts +4 -0
- package/src/cli/commands/market.ts +60 -9
- package/src/cli/commands/search.ts +43 -2
- package/src/cli/commands/ticker.ts +100 -21
- package/src/cli/desktop-pane-shot.ts +34 -9
- package/src/cli/fx-pair.ts +17 -0
- package/src/cli/glossary.ts +134 -6
- package/src/cli/history-rows.ts +16 -4
- package/src/cli/index.ts +44 -12
- package/src/cli/known-gaps.ts +21 -2
- package/src/cli/listing-arg.ts +19 -4
- package/src/cli/listing-suggestion.ts +33 -2
- package/src/cli/pane-functions/dom.ts +39 -6
- package/src/cli/pane-functions/freshness.ts +48 -5
- package/src/cli/pane-functions/function-help.ts +1 -1
- package/src/cli/pane-functions/headless.ts +85 -19
- package/src/cli/pane-functions/index.ts +60 -4
- package/src/cli/pane-functions/market-session.ts +69 -6
- package/src/cli/pane-functions/resolver.ts +4 -2
- package/src/cli/pane-functions/screenshot.ts +14 -1
- package/src/cli/scroll-truncation.ts +5 -1
- package/src/cli/symbol-args.ts +41 -3
- package/src/cli/symbol-list.ts +19 -7
- package/src/cli/unknown-command.ts +70 -0
- package/src/components/chart/native/surface/visibility.ts +37 -7
- package/src/components/index.ts +1 -1
- package/src/components/layout/header.tsx +348 -102
- package/src/components/markdown-text.tsx +40 -66
- package/src/components/market-board/index.tsx +12 -6
- package/src/components/metric-treemap/heat-surface.tsx +76 -14
- package/src/components/metric-treemap/heat-tiles.ts +5 -3
- package/src/components/metric-treemap/index.tsx +1 -1
- package/src/components/reader/detail-reader.tsx +28 -6
- package/src/components/ticker/badge/index.tsx +2 -0
- package/src/components/ui/button.tsx +24 -9
- package/src/components/ui/display.tsx +6 -1
- package/src/components/ui/external-link.tsx +10 -2
- package/src/components/ui/index.ts +1 -1
- package/src/components/ui/status.tsx +148 -5
- package/src/data/memory-resource-store.ts +1 -1
- package/src/data/resource-store.ts +13 -1
- package/src/docs/snapshot.json +13 -13
- package/src/i18n/es.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/zh-cn.ts +1 -0
- package/src/i18n/zh-tw.ts +1 -0
- package/src/layout-marketplace/gallery-desktop.tsx +2 -2
- package/src/market-data/futures-board.ts +44 -1
- package/src/market-data/futures-contract.ts +10 -0
- package/src/market-data/market/format.ts +7 -2
- package/src/market-data/market/freshness.ts +25 -1
- package/src/market-data/quotes/regular-session-valuation.ts +32 -1
- package/src/market-data/quotes/resolution.ts +2 -0
- package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
- package/src/plugins/builtin/account-management/pane.tsx +17 -13
- package/src/plugins/builtin/alerts/cli.ts +72 -11
- package/src/plugins/builtin/attention/headless.ts +19 -10
- package/src/plugins/builtin/attention/model.ts +22 -0
- package/src/plugins/builtin/awards/headless.ts +5 -1
- package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
- package/src/plugins/builtin/catalysts/headless.ts +5 -1
- package/src/plugins/builtin/changelog/index.tsx +11 -8
- package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
- package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
- package/src/plugins/builtin/chat/attachments/model.ts +5 -3
- package/src/plugins/builtin/chat/content/footer.tsx +6 -0
- package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
- package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
- package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
- package/src/plugins/builtin/chat/controller/connections.ts +18 -0
- package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
- package/src/plugins/builtin/chat/controller/index.ts +49 -21
- package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
- package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
- package/src/plugins/builtin/chat/layout.ts +91 -31
- package/src/plugins/builtin/chat/markdown.ts +94 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
- package/src/plugins/builtin/chat/message/render-state.ts +3 -0
- package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
- package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
- package/src/plugins/builtin/company-attention/headless.ts +8 -5
- package/src/plugins/builtin/company-attention/model.ts +53 -2
- package/src/plugins/builtin/company-attention/pane.tsx +1 -1
- package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
- package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
- package/src/plugins/builtin/cot/client.ts +12 -4
- package/src/plugins/builtin/cot/model.ts +20 -0
- package/src/plugins/builtin/cot/pane.tsx +2 -2
- package/src/plugins/builtin/crypto-board/model.ts +7 -3
- package/src/plugins/builtin/custom-view/pane.tsx +2 -2
- package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
- package/src/plugins/builtin/debug/index.tsx +15 -15
- package/src/plugins/builtin/dividend-yield/client.ts +8 -1
- package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
- package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
- package/src/plugins/builtin/docs/index.tsx +5 -3
- package/src/plugins/builtin/docs/pane.tsx +19 -2
- package/src/plugins/builtin/docs/targets.ts +5 -3
- package/src/plugins/builtin/earnings-calls/data.ts +22 -0
- package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
- package/src/plugins/builtin/equity-screener/headless.ts +4 -3
- package/src/plugins/builtin/equity-screener/live.ts +27 -1
- package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
- package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
- package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
- package/src/plugins/builtin/futures/index.tsx +33 -6
- package/src/plugins/builtin/futures/model.ts +69 -5
- package/src/plugins/builtin/futures/series.ts +3 -3
- package/src/plugins/builtin/futures/table.tsx +31 -11
- package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
- package/src/plugins/builtin/holders/pane.tsx +6 -1
- package/src/plugins/builtin/jobs/pane.tsx +12 -9
- package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
- package/src/plugins/builtin/mna/client.ts +12 -2
- package/src/plugins/builtin/mna/detail.tsx +2 -2
- package/src/plugins/builtin/mna/pane.tsx +2 -2
- package/src/plugins/builtin/news/headless.ts +4 -2
- package/src/plugins/builtin/notes/owner.tsx +5 -4
- package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
- package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
- package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
- package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
- package/src/plugins/builtin/research/event-model.ts +14 -0
- package/src/plugins/builtin/research/headless-schema.ts +31 -1
- package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
- package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
- package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
- package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
- package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
- package/src/plugins/builtin/research-search/data.ts +11 -0
- package/src/plugins/builtin/research-search/pane.tsx +13 -2
- package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
- package/src/plugins/builtin/seasonality/client.ts +23 -0
- package/src/plugins/builtin/seasonality/pane.tsx +4 -2
- package/src/plugins/builtin/sectors/index.tsx +7 -3
- package/src/plugins/builtin/shared/report-freshness.ts +6 -2
- package/src/plugins/builtin/short-interest/headless.ts +13 -3
- package/src/plugins/builtin/short-interest/index.tsx +1 -1
- package/src/plugins/builtin/short-interest/model.ts +62 -3
- package/src/plugins/builtin/short-interest/pane.tsx +2 -2
- package/src/plugins/builtin/short-interest/schedule.ts +107 -0
- package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
- package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
- package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
- package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
- package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
- package/src/plugins/builtin/themes/headless.ts +1 -1
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
- package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
- package/src/plugins/builtin/volatility/chart-model.ts +1 -1
- package/src/plugins/builtin/volatility/charts.tsx +1 -1
- package/src/renderers/dom/build-assets.ts +17 -4
- package/src/renderers/dom/desktop/controls.tsx +2 -0
- package/src/renderers/dom/styles.css +6 -0
- package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
- package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
- package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
- package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
- package/src/sources/provider-router/batches.ts +2 -1
- package/src/sources/provider-router/cache.ts +3 -0
- package/src/sync/baseline.ts +19 -14
- package/src/sync/controller.ts +140 -40
- package/src/sync/core-contributors.ts +256 -32
- package/src/sync/types.ts +44 -0
- package/src/time-series/resolve.ts +24 -5
- package/src/types/data-provider.ts +8 -0
- package/src/types/financials.ts +40 -4
- package/src/types/headless.ts +43 -0
- package/src/types/plugin.ts +7 -0
- package/src/ui/resize-settle.ts +128 -0
- package/src/utils/calendar-date.ts +56 -0
- package/src/utils/decimal-comma.ts +31 -0
- package/src/utils/declared-dividends.ts +39 -0
- package/src/utils/forward-dividend.ts +34 -11
- package/src/utils/fundamentals.ts +13 -2
- package/src/utils/futures-generic.ts +25 -4
- package/src/utils/markdown.ts +180 -0
- package/src/utils/market-capitalization.ts +22 -0
- package/src/utils/plugin-proxy-hosts.json +5 -0
- package/src/utils/utc-time.ts +14 -3
package/src/sync/controller.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { stableStringify } from "../utils/hash";
|
|
|
5
5
|
import { VERSION } from "../version";
|
|
6
6
|
import {
|
|
7
7
|
SYNC_SNAPSHOT_SCHEMA_VERSION,
|
|
8
|
+
SyncRevisionConflictError,
|
|
8
9
|
type RegisteredSyncContributor,
|
|
9
10
|
type RegisteredSyncTransport,
|
|
10
11
|
type SyncBaselineStore,
|
|
@@ -35,6 +36,16 @@ interface SyncRuntime {
|
|
|
35
36
|
|
|
36
37
|
const CLIENT_ID_STORAGE_KEY = "gloomberb.sync.clientId";
|
|
37
38
|
const PUSH_DEBOUNCE_MS = 2500;
|
|
39
|
+
/** How soon after a change contributors date it, so an edit made just before quitting still is. */
|
|
40
|
+
const OBSERVE_DEBOUNCE_MS = 250;
|
|
41
|
+
/** Pushes refused because another device wrote first, each followed by a fresh pull, before giving up until the next sync. */
|
|
42
|
+
const MAX_CONFLICT_RETRIES = 3;
|
|
43
|
+
|
|
44
|
+
/** What one pull left behind: the stored revision it merged with (0 for none) and that copy's content. */
|
|
45
|
+
interface PullResult {
|
|
46
|
+
revision: number | null;
|
|
47
|
+
signature: string | null;
|
|
48
|
+
}
|
|
38
49
|
|
|
39
50
|
function nowIso(): string {
|
|
40
51
|
return new Date().toISOString();
|
|
@@ -49,13 +60,17 @@ function resolveClientId(): string {
|
|
|
49
60
|
return id;
|
|
50
61
|
}
|
|
51
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The content of a snapshot as it reads once stored, so one assembled here
|
|
65
|
+
* (with fields left undefined) compares equal to the same one pulled back.
|
|
66
|
+
*/
|
|
52
67
|
function snapshotContentSignature(snapshot: SyncSnapshot): string {
|
|
53
|
-
return stableStringify(Object.fromEntries(
|
|
68
|
+
return stableStringify(JSON.parse(JSON.stringify(Object.fromEntries(
|
|
54
69
|
Object.entries(snapshot.contributors).map(([id, contributor]) => [
|
|
55
70
|
id,
|
|
56
71
|
{ schemaVersion: contributor.schemaVersion, payload: contributor.payload },
|
|
57
72
|
]),
|
|
58
|
-
));
|
|
73
|
+
))));
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
export class CloudSyncController {
|
|
@@ -64,6 +79,10 @@ export class CloudSyncController {
|
|
|
64
79
|
private transports = new Map<string, RegisteredSyncTransport>();
|
|
65
80
|
private listeners = new Set<() => void>();
|
|
66
81
|
private pushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
82
|
+
private observeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
83
|
+
private observedSinceStart = false;
|
|
84
|
+
private memos: Record<string, unknown> | null = null;
|
|
85
|
+
private memosLoaded = false;
|
|
67
86
|
private inFlight: Promise<void> | null = null;
|
|
68
87
|
private syncQueued = false;
|
|
69
88
|
private clientId: string | null = null;
|
|
@@ -141,6 +160,7 @@ export class CloudSyncController {
|
|
|
141
160
|
schedulePush(reason: string): void {
|
|
142
161
|
const runtime = this.runtime;
|
|
143
162
|
if (!runtime) return;
|
|
163
|
+
this.scheduleObserve(runtime);
|
|
144
164
|
const registration = runtime.getTransport();
|
|
145
165
|
if (!registration?.transport.isAvailable()) {
|
|
146
166
|
this.setStatus({
|
|
@@ -176,6 +196,7 @@ export class CloudSyncController {
|
|
|
176
196
|
private async syncOnce(options: { reason?: string; force?: boolean }): Promise<void> {
|
|
177
197
|
const runtime = this.runtime;
|
|
178
198
|
if (!runtime) return;
|
|
199
|
+
this.observe(runtime);
|
|
179
200
|
const registration = runtime.getTransport();
|
|
180
201
|
if (!registration?.transport.isAvailable()) {
|
|
181
202
|
this.setStatus({
|
|
@@ -187,53 +208,72 @@ export class CloudSyncController {
|
|
|
187
208
|
}
|
|
188
209
|
const transport = registration.transport;
|
|
189
210
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
211
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
212
|
+
// Always pull before push. Another device (TUI, browser, phone) may have
|
|
213
|
+
// written since this process last synced, and contributors already merge
|
|
214
|
+
// so local-only edits survive the apply.
|
|
215
|
+
const pulled = await this.runPull(runtime, transport);
|
|
216
|
+
if (!pulled || !this.isCurrent(runtime, transport)) return;
|
|
195
217
|
|
|
196
|
-
|
|
197
|
-
if (!this.isCurrent(runtime, transport)) return;
|
|
198
|
-
const signature = snapshotContentSignature(snapshot);
|
|
199
|
-
if (!options.force && signature === this.lastSignature) return;
|
|
200
|
-
|
|
201
|
-
this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
|
|
202
|
-
try {
|
|
203
|
-
const result = await transport.pushSnapshot(snapshot, { baseRevision: this.status.revision });
|
|
204
|
-
if (!this.isCurrent(runtime, transport)) return;
|
|
205
|
-
this.lastSignature = signature;
|
|
206
|
-
this.appliedRevision = result.revision;
|
|
207
|
-
this.saveBaseline(runtime, snapshot);
|
|
208
|
-
this.setStatus({
|
|
209
|
-
phase: "synced",
|
|
210
|
-
transportId: transport.id,
|
|
211
|
-
revision: result.revision,
|
|
212
|
-
lastSyncAt: result.updatedAt,
|
|
213
|
-
error: null,
|
|
214
|
-
});
|
|
215
|
-
} catch (error) {
|
|
218
|
+
const snapshot = await this.assembleSnapshot(runtime);
|
|
216
219
|
if (!this.isCurrent(runtime, transport)) return;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
const signature = snapshotContentSignature(snapshot);
|
|
221
|
+
if (!options.force && signature === this.lastSignature) return;
|
|
222
|
+
if (!options.force && signature === pulled.signature) {
|
|
223
|
+
// The cloud already holds exactly this: pushing it back would only
|
|
224
|
+
// bump the revision under another device's next push.
|
|
225
|
+
this.lastSignature = signature;
|
|
226
|
+
this.saveBaseline(runtime, snapshot);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
|
|
231
|
+
try {
|
|
232
|
+
// The revision this snapshot was merged with. The cloud refuses the
|
|
233
|
+
// write if another device wrote since, instead of losing that write.
|
|
234
|
+
const result = await transport.pushSnapshot(snapshot, { baseRevision: pulled.revision });
|
|
235
|
+
if (!this.isCurrent(runtime, transport)) return;
|
|
236
|
+
this.lastSignature = signature;
|
|
237
|
+
this.appliedRevision = result.revision;
|
|
238
|
+
this.saveBaseline(runtime, snapshot);
|
|
239
|
+
this.setStatus({
|
|
240
|
+
phase: "synced",
|
|
241
|
+
transportId: transport.id,
|
|
242
|
+
revision: result.revision,
|
|
243
|
+
lastSyncAt: result.updatedAt,
|
|
244
|
+
error: null,
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (!this.isCurrent(runtime, transport)) return;
|
|
249
|
+
if (error instanceof SyncRevisionConflictError && attempt < MAX_CONFLICT_RETRIES) {
|
|
250
|
+
// Pull whatever the other device wrote, even an older revision than
|
|
251
|
+
// this process last saw (an account switch, a restored snapshot).
|
|
252
|
+
this.appliedRevision = null;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
this.setStatus({
|
|
256
|
+
phase: "error",
|
|
257
|
+
transportId: transport.id,
|
|
258
|
+
error: error instanceof Error ? error.message : String(error),
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
222
262
|
}
|
|
223
263
|
}
|
|
224
264
|
|
|
225
|
-
private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<
|
|
265
|
+
private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<PullResult | null> {
|
|
226
266
|
const baselineState = runtime.getState();
|
|
227
267
|
this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
|
|
228
268
|
try {
|
|
229
269
|
const response = await transport.pullSnapshot();
|
|
230
|
-
if (!this.isCurrent(runtime, transport)) return
|
|
270
|
+
if (!this.isCurrent(runtime, transport)) return null;
|
|
231
271
|
|
|
232
272
|
if (response.snapshot && this.shouldApplyPulledRevision(response.revision)) {
|
|
233
273
|
this.assertSnapshotCompatible(response.snapshot, runtime.getContributors());
|
|
234
274
|
this.pulledContributors = response.snapshot.contributors;
|
|
235
275
|
for (const entry of runtime.getContributors()) {
|
|
236
|
-
if (!this.isCurrent(runtime, transport)) return
|
|
276
|
+
if (!this.isCurrent(runtime, transport)) return null;
|
|
237
277
|
const contributorPayload = response.snapshot.contributors[entry.contributor.id];
|
|
238
278
|
if (!contributorPayload || !entry.contributor.apply) continue;
|
|
239
279
|
await entry.contributor.apply(contributorPayload.payload, {
|
|
@@ -245,12 +285,13 @@ export class CloudSyncController {
|
|
|
245
285
|
isCurrent: () => this.isCurrent(runtime, transport),
|
|
246
286
|
dispatch: runtime.dispatch,
|
|
247
287
|
tickerRepository: runtime.tickerRepository,
|
|
288
|
+
memo: this.memoFor(runtime, entry.contributor.id),
|
|
248
289
|
});
|
|
249
290
|
}
|
|
250
291
|
if (response.revision != null) this.appliedRevision = response.revision;
|
|
251
292
|
}
|
|
252
293
|
|
|
253
|
-
if (!this.isCurrent(runtime, transport)) return
|
|
294
|
+
if (!this.isCurrent(runtime, transport)) return null;
|
|
254
295
|
this.setStatus({
|
|
255
296
|
phase: response.snapshot ? "synced" : "idle",
|
|
256
297
|
transportId: transport.id,
|
|
@@ -259,15 +300,18 @@ export class CloudSyncController {
|
|
|
259
300
|
lastSyncAt: response.updatedAt ?? this.status.lastSyncAt,
|
|
260
301
|
error: null,
|
|
261
302
|
});
|
|
262
|
-
return
|
|
303
|
+
return {
|
|
304
|
+
revision: response.snapshot ? response.revision : 0,
|
|
305
|
+
signature: response.snapshot ? snapshotContentSignature(response.snapshot) : null,
|
|
306
|
+
};
|
|
263
307
|
} catch (error) {
|
|
264
|
-
if (!this.isCurrent(runtime, transport)) return
|
|
308
|
+
if (!this.isCurrent(runtime, transport)) return null;
|
|
265
309
|
this.setStatus({
|
|
266
310
|
phase: "error",
|
|
267
311
|
transportId: transport.id,
|
|
268
312
|
error: error instanceof Error ? error.message : String(error),
|
|
269
313
|
});
|
|
270
|
-
return
|
|
314
|
+
return null;
|
|
271
315
|
}
|
|
272
316
|
}
|
|
273
317
|
|
|
@@ -302,7 +346,11 @@ export class CloudSyncController {
|
|
|
302
346
|
// carried over instead of being deleted for every other device.
|
|
303
347
|
const payloads: SyncSnapshot["contributors"] = { ...this.pulledContributors };
|
|
304
348
|
for (const entry of contributors) {
|
|
305
|
-
const payload = await entry.contributor.collect({
|
|
349
|
+
const payload = await entry.contributor.collect({
|
|
350
|
+
state: runtime.getState(),
|
|
351
|
+
baselinePayload: this.loadBaseline(runtime)?.[entry.contributor.id] ?? null,
|
|
352
|
+
memo: this.memoFor(runtime, entry.contributor.id),
|
|
353
|
+
});
|
|
306
354
|
payloads[entry.contributor.id] = {
|
|
307
355
|
schemaVersion: entry.contributor.schemaVersion,
|
|
308
356
|
updatedAt: createdAt,
|
|
@@ -327,6 +375,53 @@ export class CloudSyncController {
|
|
|
327
375
|
return this.baseline;
|
|
328
376
|
}
|
|
329
377
|
|
|
378
|
+
private scheduleObserve(runtime: SyncRuntime): void {
|
|
379
|
+
if (this.observeTimer) clearTimeout(this.observeTimer);
|
|
380
|
+
this.observeTimer = setTimeout(() => {
|
|
381
|
+
this.observeTimer = null;
|
|
382
|
+
if (this.runtime === runtime) this.observe(runtime);
|
|
383
|
+
}, OBSERVE_DEBOUNCE_MS);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Lets contributors date local edits as they happen, whether or not the cloud is reachable. */
|
|
387
|
+
private observe(runtime: SyncRuntime): void {
|
|
388
|
+
const launch = !this.observedSinceStart;
|
|
389
|
+
this.observedSinceStart = true;
|
|
390
|
+
for (const entry of runtime.getContributors()) {
|
|
391
|
+
if (!entry.contributor.observe) continue;
|
|
392
|
+
try {
|
|
393
|
+
entry.contributor.observe({
|
|
394
|
+
state: runtime.getState(),
|
|
395
|
+
baselinePayload: this.loadBaseline(runtime)?.[entry.contributor.id] ?? null,
|
|
396
|
+
memo: this.memoFor(runtime, entry.contributor.id),
|
|
397
|
+
launch,
|
|
398
|
+
});
|
|
399
|
+
} catch {
|
|
400
|
+
// A contributor that cannot date an edit leaves it undated, which only
|
|
401
|
+
// makes it lose a conflict it might have won.
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private memoFor(runtime: SyncRuntime, contributorId: string) {
|
|
407
|
+
return {
|
|
408
|
+
get: () => this.loadMemos(runtime)[contributorId] ?? null,
|
|
409
|
+
set: (value: unknown) => {
|
|
410
|
+
const memos = { ...this.loadMemos(runtime), [contributorId]: value };
|
|
411
|
+
this.memos = memos;
|
|
412
|
+
runtime.baselineStore?.saveMemos?.(memos);
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private loadMemos(runtime: SyncRuntime): Record<string, unknown> {
|
|
418
|
+
if (!this.memosLoaded) {
|
|
419
|
+
this.memosLoaded = true;
|
|
420
|
+
this.memos = runtime.baselineStore?.loadMemos?.() ?? null;
|
|
421
|
+
}
|
|
422
|
+
return this.memos ?? {};
|
|
423
|
+
}
|
|
424
|
+
|
|
330
425
|
private saveBaseline(runtime: SyncRuntime, snapshot: SyncSnapshot): void {
|
|
331
426
|
const payloads = Object.fromEntries(
|
|
332
427
|
Object.entries(snapshot.contributors).map(([id, contributor]) => [id, contributor.payload]),
|
|
@@ -353,6 +448,11 @@ export class CloudSyncController {
|
|
|
353
448
|
private resetOperations(): void {
|
|
354
449
|
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
355
450
|
this.pushTimer = null;
|
|
451
|
+
if (this.observeTimer) clearTimeout(this.observeTimer);
|
|
452
|
+
this.observeTimer = null;
|
|
453
|
+
this.observedSinceStart = false;
|
|
454
|
+
this.memos = null;
|
|
455
|
+
this.memosLoaded = false;
|
|
356
456
|
this.inFlight = null;
|
|
357
457
|
this.syncQueued = false;
|
|
358
458
|
this.lastSignature = null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { scheduleConfigSave } from "../state/config-save-scheduler";
|
|
2
2
|
import { debugLog } from "../utils/debug-log";
|
|
3
|
-
import { stableStringify } from "../utils/hash";
|
|
3
|
+
import { fnv1aHex, stableStringify } from "../utils/hash";
|
|
4
4
|
import type { AppConfig, BrokerInstanceConfig, SavedLayout } from "../types/config";
|
|
5
5
|
import type { PricePoint, TickerFinancials } from "../types/financials";
|
|
6
6
|
import type { Portfolio, TickerMetadata, TickerPosition, TickerRecord, Watchlist } from "../types/ticker";
|
|
@@ -40,6 +40,9 @@ import { withoutMissingWatchlists } from "../plugins/builtin/portfolio-list/muta
|
|
|
40
40
|
*/
|
|
41
41
|
const SESSION_SAVED_LAYOUT_KEYS = ["paneState", "focusedPaneId", "activePanel"] as const;
|
|
42
42
|
|
|
43
|
+
/** Together these are the workspace: the tabs, the one showing, and its arrangement. */
|
|
44
|
+
const LAYOUT_SYNC_KEYS = ["layout", "layouts", "activeLayoutIndex"] as const;
|
|
45
|
+
|
|
43
46
|
const SENSITIVE_KEY_PATTERN = /(token|secret|password|credential|private|api[_-]?key|access[_-]?key|refresh[_-]?key|session|cookie|dataDir|path|directory|localPath)/i;
|
|
44
47
|
|
|
45
48
|
const log = debugLog.createLogger("sync");
|
|
@@ -351,6 +354,14 @@ function sanitizeTickerMetadata(metadata: TickerMetadata, financials?: TickerFin
|
|
|
351
354
|
};
|
|
352
355
|
}
|
|
353
356
|
|
|
357
|
+
function collectLayoutPayload(config: Pick<AppConfig, typeof LAYOUT_SYNC_KEYS[number]>) {
|
|
358
|
+
return sanitizeUnknown({
|
|
359
|
+
layout: config.layout,
|
|
360
|
+
layouts: config.layouts.map(withoutSessionLayoutState),
|
|
361
|
+
activeLayoutIndex: config.activeLayoutIndex,
|
|
362
|
+
}) as Record<string, unknown>;
|
|
363
|
+
}
|
|
364
|
+
|
|
354
365
|
function collectCoreConfigPayload(config: AppConfig) {
|
|
355
366
|
return sanitizeUnknown({
|
|
356
367
|
configVersion: config.configVersion,
|
|
@@ -358,9 +369,7 @@ function collectCoreConfigPayload(config: AppConfig) {
|
|
|
358
369
|
refreshIntervalMinutes: config.refreshIntervalMinutes,
|
|
359
370
|
portfolios: config.portfolios.map(sanitizePortfolio),
|
|
360
371
|
watchlists: config.watchlists.map(sanitizeWatchlist),
|
|
361
|
-
|
|
362
|
-
layouts: config.layouts.map(withoutSessionLayoutState),
|
|
363
|
-
activeLayoutIndex: config.activeLayoutIndex,
|
|
372
|
+
...collectLayoutPayload(config),
|
|
364
373
|
brokerInstances: config.brokerInstances.map(sanitizeBrokerInstance),
|
|
365
374
|
// This build merges profiles one by one before it pushes, so a profile
|
|
366
375
|
// missing from its list was removed, not one it never pulled.
|
|
@@ -622,11 +631,204 @@ function withLocalSessionLayoutState(
|
|
|
622
631
|
});
|
|
623
632
|
}
|
|
624
633
|
|
|
634
|
+
/**
|
|
635
|
+
* A layout as the user arranged it. Stacking order follows whichever floating
|
|
636
|
+
* pane was focused last, and a pane in its own desktop window is one a
|
|
637
|
+
* terminal draws inline as a floating pane, so neither tells two copies apart.
|
|
638
|
+
*/
|
|
639
|
+
function arrangedLayout(layout: unknown): unknown {
|
|
640
|
+
if (!isRecord(layout)) return layout;
|
|
641
|
+
const windows = [
|
|
642
|
+
...(Array.isArray(layout.floating) ? layout.floating : []),
|
|
643
|
+
...(Array.isArray(layout.detached) ? layout.detached : []),
|
|
644
|
+
].map((entry) => {
|
|
645
|
+
if (!isRecord(entry)) return entry;
|
|
646
|
+
const { zIndex: _zIndex, ...rest } = entry;
|
|
647
|
+
return rest;
|
|
648
|
+
});
|
|
649
|
+
const instanceId = (entry: unknown) => (isRecord(entry) ? String(entry.instanceId) : "");
|
|
650
|
+
windows.sort((left, right) => instanceId(left).localeCompare(instanceId(right)));
|
|
651
|
+
return { ...layout, floating: windows, detached: [] };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Pulled and last synced payloads are compared on every sync and never change.
|
|
655
|
+
const layoutIntents = new WeakMap<object, string | null>();
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* A key for the workspace a payload describes, equal for two copies that only
|
|
659
|
+
* differ in what reflects one device, so a real edit can be told from them.
|
|
660
|
+
* Null when the payload holds no complete workspace.
|
|
661
|
+
*/
|
|
662
|
+
function layoutIntent(payload: unknown): string | null {
|
|
663
|
+
if (!isRecord(payload) || !Array.isArray(payload.layouts)) return null;
|
|
664
|
+
if (!LAYOUT_SYNC_KEYS.every((key) => key in payload)) return null;
|
|
665
|
+
const cached = layoutIntents.get(payload);
|
|
666
|
+
if (cached !== undefined) return cached;
|
|
667
|
+
const intent = stableStringify(sanitizeUnknown({
|
|
668
|
+
layout: arrangedLayout(payload.layout),
|
|
669
|
+
layouts: payload.layouts.map((entry) => (
|
|
670
|
+
isRecord(entry)
|
|
671
|
+
? { ...withoutSessionLayoutState(entry as unknown as SavedLayout), layout: arrangedLayout(entry.layout) }
|
|
672
|
+
: entry
|
|
673
|
+
)),
|
|
674
|
+
activeLayoutIndex: payload.activeLayoutIndex,
|
|
675
|
+
}));
|
|
676
|
+
const key = `${fnv1aHex(intent)}:${intent.length}`;
|
|
677
|
+
layoutIntents.set(payload, key);
|
|
678
|
+
return key;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** The workspace keys of a payload, with the time its layout was last edited when it says. */
|
|
682
|
+
function pickLayoutPayload(payload: Record<string, unknown>): Record<string, unknown> {
|
|
683
|
+
const picked = Object.fromEntries([...LAYOUT_SYNC_KEYS, "layoutEditedAt"].flatMap((key) => (
|
|
684
|
+
key in payload ? [[key, payload[key]]] : []
|
|
685
|
+
)));
|
|
686
|
+
// An older build still sent the view it was looking at with each layout.
|
|
687
|
+
if (Array.isArray(picked.layouts)) {
|
|
688
|
+
picked.layouts = picked.layouts.map((entry) => (isRecord(entry) ? withoutSessionLayoutState(entry as unknown as SavedLayout) : entry));
|
|
689
|
+
}
|
|
690
|
+
return picked;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* What this device remembers about its workspace between syncs: when it last
|
|
695
|
+
* edited it (null when that happened while the app was closed, so the time is
|
|
696
|
+
* unknown), or, when it took the workspace from the cloud, that copy as synced.
|
|
697
|
+
*/
|
|
698
|
+
interface LayoutMemo {
|
|
699
|
+
intent: string;
|
|
700
|
+
editedAt: number | null;
|
|
701
|
+
synced?: Record<string, unknown>;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function readLayoutMemo(value: unknown): LayoutMemo | null {
|
|
705
|
+
if (!isRecord(value) || typeof value.intent !== "string") return null;
|
|
706
|
+
return {
|
|
707
|
+
intent: value.intent,
|
|
708
|
+
editedAt: typeof value.editedAt === "number" && Number.isFinite(value.editedAt) ? value.editedAt : null,
|
|
709
|
+
...(isRecord(value.synced) ? { synced: value.synced } : {}),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* When a pulled workspace was last edited. A copy from a build that did not
|
|
715
|
+
* date edits counts as edited when it was pushed, which is never earlier.
|
|
716
|
+
*/
|
|
717
|
+
function pulledLayoutEditedAt(payload: Record<string, unknown>, pushedAt: string | undefined): number {
|
|
718
|
+
if (typeof payload.layoutEditedAt === "number" && Number.isFinite(payload.layoutEditedAt)) return payload.layoutEditedAt;
|
|
719
|
+
const pushed = pushedAt ? Date.parse(pushedAt) : Number.NaN;
|
|
720
|
+
return Number.isFinite(pushed) ? pushed : Number.POSITIVE_INFINITY;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Whether a pulled workspace replaces this device's, three ways: against the
|
|
725
|
+
* copy this device last synced, the one it had when the pull started, and the
|
|
726
|
+
* pulled one. Only a real edit here keeps the local copy; the pane state,
|
|
727
|
+
* focus and window stacking that change on every launch never do.
|
|
728
|
+
*/
|
|
729
|
+
function shouldAdoptPulledLayout({
|
|
730
|
+
local,
|
|
731
|
+
atPullStart,
|
|
732
|
+
lastSynced,
|
|
733
|
+
pulled,
|
|
734
|
+
memo,
|
|
735
|
+
pulledEditedAt,
|
|
736
|
+
}: {
|
|
737
|
+
local: string | null;
|
|
738
|
+
atPullStart: string | null;
|
|
739
|
+
lastSynced: string | null;
|
|
740
|
+
pulled: string | null;
|
|
741
|
+
memo: LayoutMemo | null;
|
|
742
|
+
pulledEditedAt: number;
|
|
743
|
+
}): boolean {
|
|
744
|
+
if (pulled === null || pulled === local) return false;
|
|
745
|
+
// Edited while the pull was in flight: newer than anything it carries.
|
|
746
|
+
if (local !== atPullStart) return false;
|
|
747
|
+
const takenFromCloud = !!memo?.synced && memo.intent === local;
|
|
748
|
+
const editedHere = lastSynced !== null && local !== lastSynced && !takenFromCloud;
|
|
749
|
+
if (!editedHere) return true;
|
|
750
|
+
// The cloud still holds what this device last synced: only this side changed.
|
|
751
|
+
if (pulled === lastSynced) return false;
|
|
752
|
+
// Both sides changed since: the later edit wins, and an undated local one loses.
|
|
753
|
+
const localEditedAt = memo?.intent === local ? memo.editedAt : null;
|
|
754
|
+
return localEditedAt === null || localEditedAt <= pulledEditedAt;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* The workspace as the cloud already has it unless this device really edited
|
|
759
|
+
* it, so what only reflects this device never replaces another device's copy,
|
|
760
|
+
* and a real edit goes out dated.
|
|
761
|
+
*/
|
|
762
|
+
function withLayoutProvenance(
|
|
763
|
+
payload: Record<string, unknown>,
|
|
764
|
+
lastSyncedPayload: unknown,
|
|
765
|
+
memoValue: unknown,
|
|
766
|
+
): Record<string, unknown> {
|
|
767
|
+
const local = layoutIntent(payload);
|
|
768
|
+
if (local === null) return payload;
|
|
769
|
+
const memo = readLayoutMemo(memoValue);
|
|
770
|
+
if (memo?.synced && memo.intent === local) return { ...payload, ...memo.synced };
|
|
771
|
+
if (isRecord(lastSyncedPayload) && layoutIntent(lastSyncedPayload) === local) {
|
|
772
|
+
return { ...payload, ...pickLayoutPayload(lastSyncedPayload) };
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
...payload,
|
|
776
|
+
layoutEditedAt: memo?.intent === local && memo.editedAt !== null ? memo.editedAt : Date.now(),
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Dates a workspace edit when it happens, online or not, so a later conflict
|
|
782
|
+
* with another device's edit goes to the newer one.
|
|
783
|
+
*/
|
|
784
|
+
function noteLayoutEdit(config: AppConfig, lastSyncedPayload: unknown, memoValue: unknown, launch: boolean): LayoutMemo | null {
|
|
785
|
+
const lastSynced = layoutIntent(lastSyncedPayload);
|
|
786
|
+
if (lastSynced === null) return null;
|
|
787
|
+
const local = layoutIntent(collectLayoutPayload(config));
|
|
788
|
+
if (local === null || local === lastSynced) return null;
|
|
789
|
+
if (readLayoutMemo(memoValue)?.intent === local) return null;
|
|
790
|
+
// Seen at launch, it was made while the app was closed or just before it
|
|
791
|
+
// quit; when is unknown, so it gives way to a dated edit elsewhere.
|
|
792
|
+
return { intent: local, editedAt: launch ? null : Date.now() };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Once this device holds the cloud's workspace, by taking it or because the
|
|
797
|
+
* two already matched, it remembers that copy as synced, keyed by how it
|
|
798
|
+
* looks here once applied. The next push then sends the cloud's own copy back
|
|
799
|
+
* rather than this device's rendering of it.
|
|
800
|
+
*/
|
|
801
|
+
function rememberCloudLayout(
|
|
802
|
+
payload: Record<string, unknown>,
|
|
803
|
+
pushedAt: string | undefined,
|
|
804
|
+
applied: AppConfig,
|
|
805
|
+
memo: { get(): unknown; set(value: unknown): void },
|
|
806
|
+
adopted: boolean,
|
|
807
|
+
): void {
|
|
808
|
+
const local = layoutIntent(collectLayoutPayload(applied));
|
|
809
|
+
if (local === null || (!adopted && local !== layoutIntent(payload))) return;
|
|
810
|
+
const current = readLayoutMemo(memo.get());
|
|
811
|
+
if (!adopted && current?.synced && current.intent === local) return;
|
|
812
|
+
const editedAt = pulledLayoutEditedAt(payload, pushedAt);
|
|
813
|
+
memo.set({
|
|
814
|
+
intent: local,
|
|
815
|
+
editedAt: Number.isFinite(editedAt) ? editedAt : null,
|
|
816
|
+
synced: pickLayoutPayload(payload),
|
|
817
|
+
} satisfies LayoutMemo);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
interface LayoutMergeContext {
|
|
821
|
+
memo?: unknown;
|
|
822
|
+
/** When the pulled copy was pushed, for one from a build that did not date layout edits. */
|
|
823
|
+
pushedAt?: string;
|
|
824
|
+
}
|
|
825
|
+
|
|
625
826
|
function mergeConfigPayload(
|
|
626
827
|
config: AppConfig,
|
|
627
828
|
payload: unknown,
|
|
628
829
|
baselineConfig: AppConfig = config,
|
|
629
830
|
lastSyncedPayload?: unknown,
|
|
831
|
+
layoutContext: LayoutMergeContext = {},
|
|
630
832
|
): AppConfig | null {
|
|
631
833
|
if (!isRecord(payload)) return null;
|
|
632
834
|
const next: AppConfig = { ...config };
|
|
@@ -634,11 +836,9 @@ function mergeConfigPayload(
|
|
|
634
836
|
// was in flight; lastSyncedPayload catches edits made while the app was not
|
|
635
837
|
// running at all (CLI writes, offline edits), which otherwise look pristine.
|
|
636
838
|
const lastSynced = isRecord(lastSyncedPayload) ? lastSyncedPayload : null;
|
|
637
|
-
const localPayload =
|
|
638
|
-
? collectCoreConfigPayload(config) as Record<string, unknown>
|
|
639
|
-
: null;
|
|
839
|
+
const localPayload = collectCoreConfigPayload(config) as Record<string, unknown>;
|
|
640
840
|
const matchesLastSynced = (key: string) => (
|
|
641
|
-
!lastSynced || valuesEqual(localPayload
|
|
841
|
+
!lastSynced || valuesEqual(localPayload[key], lastSynced[key])
|
|
642
842
|
);
|
|
643
843
|
const canApply = <K extends keyof AppConfig>(key: K) => (
|
|
644
844
|
valuesEqual(config[key], baselineConfig[key]) && matchesLastSynced(key as string)
|
|
@@ -689,15 +889,20 @@ function mergeConfigPayload(
|
|
|
689
889
|
);
|
|
690
890
|
}
|
|
691
891
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
892
|
+
// Compared by content, not identity: the active layout mirrors pane state,
|
|
893
|
+
// so a cursor settling at launch replaced it mid-pull, the pulled workspace
|
|
894
|
+
// was refused, and the push that followed wrote this device's older one
|
|
895
|
+
// over it on every other device.
|
|
896
|
+
const localIntent = layoutIntent(localPayload);
|
|
897
|
+
const adoptLayout = shouldAdoptPulledLayout({
|
|
898
|
+
local: localIntent,
|
|
899
|
+
atPullStart: baselineConfig === config ? localIntent : layoutIntent(collectLayoutPayload(baselineConfig)),
|
|
900
|
+
lastSynced: layoutIntent(lastSynced),
|
|
901
|
+
pulled: layoutIntent(payload),
|
|
902
|
+
memo: readLayoutMemo(layoutContext.memo),
|
|
903
|
+
pulledEditedAt: pulledLayoutEditedAt(payload, layoutContext.pushedAt),
|
|
904
|
+
});
|
|
905
|
+
if (adoptLayout) {
|
|
701
906
|
next.layout = payload.layout as unknown as AppConfig["layout"];
|
|
702
907
|
next.layouts = withLocalSessionLayoutState(payload.layouts as AppConfig["layouts"], config.layouts);
|
|
703
908
|
next.activeLayoutIndex = payload.activeLayoutIndex as number;
|
|
@@ -872,24 +1077,43 @@ function tickerChangedSinceLastSync(
|
|
|
872
1077
|
return !valuesEqual(withoutQuote(local), withoutQuote(baseline));
|
|
873
1078
|
}
|
|
874
1079
|
|
|
1080
|
+
const CORE_CONFIG_CONTRIBUTOR_ID = "core.config";
|
|
1081
|
+
|
|
875
1082
|
export const coreConfigSyncContributor: SyncContributor = {
|
|
876
|
-
id:
|
|
1083
|
+
id: CORE_CONFIG_CONTRIBUTOR_ID,
|
|
877
1084
|
schemaVersion: 1,
|
|
878
|
-
collect: ({ state }) =>
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
if (
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1085
|
+
collect: ({ state, baselinePayload, memo }) => withLayoutProvenance(
|
|
1086
|
+
collectCoreConfigPayload(state.config) as Record<string, unknown>,
|
|
1087
|
+
baselinePayload,
|
|
1088
|
+
memo?.get(),
|
|
1089
|
+
),
|
|
1090
|
+
observe: ({ state, baselinePayload, memo, launch }) => {
|
|
1091
|
+
const edit = noteLayoutEdit(state.config, baselinePayload, memo.get(), launch);
|
|
1092
|
+
if (edit) memo.set(edit);
|
|
1093
|
+
},
|
|
1094
|
+
apply: async (payload, { snapshot, baselinePayload, baselineState, state, getState, isCurrent, dispatch, tickerRepository, memo }) => {
|
|
1095
|
+
const pushedAt = snapshot?.contributors[CORE_CONFIG_CONTRIBUTOR_ID]?.updatedAt;
|
|
1096
|
+
const nextConfig = mergeConfigPayload(state.config, payload, baselineState.config, baselinePayload, {
|
|
1097
|
+
memo: memo?.get(),
|
|
1098
|
+
pushedAt,
|
|
1099
|
+
});
|
|
1100
|
+
if (!nextConfig) return;
|
|
1101
|
+
const configChanged = !valuesEqual(nextConfig, state.config);
|
|
1102
|
+
if (configChanged) {
|
|
1103
|
+
// Adopting another device's workspace rearranges the screen under the
|
|
1104
|
+
// user, so it is worth a line in the log when a report says "my panes
|
|
1105
|
+
// moved on their own".
|
|
1106
|
+
if (nextConfig.layout !== state.config.layout) {
|
|
1107
|
+
log.info("config.layout.adopted", {
|
|
1108
|
+
panes: nextConfig.layout.instances.length,
|
|
1109
|
+
activeLayoutIndex: nextConfig.activeLayoutIndex,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
dispatch({ type: "SET_CONFIG", config: nextConfig });
|
|
1113
|
+
scheduleConfigSave(nextConfig);
|
|
890
1114
|
}
|
|
891
|
-
|
|
892
|
-
|
|
1115
|
+
if (memo && isRecord(payload)) rememberCloudLayout(payload, pushedAt, getState().config, memo, nextConfig.layouts !== state.config.layouts);
|
|
1116
|
+
if (!configChanged) return;
|
|
893
1117
|
|
|
894
1118
|
// A profile removed on another device takes its positions with it here
|
|
895
1119
|
// too; kept, this device would push them back and recreate the tickers
|