This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch fix/editor-source-default-remote in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 82af241bc6b334b9187dc6404df4a3c1a76bfce4 Author: Wu Sheng <[email protected]> AuthorDate: Tue May 26 21:24:06 2026 +0800 fix(admin): default editor to remote + reset truly discards local draft Operator-facing fixes across the three admin editors (Layer Dashboards, Overview Templates, Translations): 1. **Editor source default = remote** (Layer + Overview). The runtime bundle serves remote content to end users for synced / diverged / remote-only rows; the editor now opens from remote on every (re-)mount, with local draft winning when present and bundled only on bundled-fallback rows. 2. **Source pill — three distinct states** (Layer + Overview). - Remote (default) → no pill. - After Reset to bundled (no local edits yet) → "from bundled". - After save / typed local edits → "from local". The bundled-vs-local distinction matters: reset stages bundled for inspection / push, local edits are typed content not on disk anywhere. 3. **Source pill on Translations follows the same vocabulary.** As soon as the operator has a staged local draft for the current (template, locale), a "from local" chip surfaces in the editor header. Discard puts the editor back on the default (OAP overlay row) and the chip disappears. 4. **Reset → Bundled stays visible even when synced** (Layer + Overview). Previously hidden via `v-if="!isSynced"`; now always rendered and disabled with a "(synced)" tail when bundled equals remote so the affordance stays visible while clearly signalling nothing meaningful to reset to. 5. **Reset really discards** (Layer + Overview). "Reset to bundled" / "Reset to remote" advertised "Discard current edits …", but the implementation called `persistLocal(new_content)` after `loadFrom(src)` — writing the freshly-loaded content into localEdits as a brand-new draft and triggering the TemplateConflictPrompt right after every reset. Now calls `localEdits.remove(editName)`; subsequent edits re-create a draft naturally on the next change. 6. **Picker filters auto-uncheck when their set empties** (Layer + Overview). Reset OAP to bundled / Push / Sync on the last row matching the active filter (e.g. the last Diverged layer) used to leave the operator stranded — the filter stuck on Diverged, the list went empty, and the checkbox itself was disabled because `divergedCount === 0`, so the operator couldn't uncheck to recover. Reactive guard watches both diverged + local counts and clears the matching filter when it goes to zero. Adds the new pill / dropdown labels + tooltips to all eight locale catalogs. --- .../admin/layer-templates/LayerDashboardsAdmin.vue | 128 ++++++++++++---- .../overview-templates/OverviewTemplatesAdmin.vue | 120 ++++++++++----- .../admin/translations/TranslationsView.vue | 166 +++++++++++++++++++-- apps/ui/src/i18n/locales/de.json | 24 ++- apps/ui/src/i18n/locales/en.json | 24 ++- apps/ui/src/i18n/locales/es.json | 24 ++- apps/ui/src/i18n/locales/fr.json | 24 ++- apps/ui/src/i18n/locales/ja.json | 24 ++- apps/ui/src/i18n/locales/ko.json | 24 ++- apps/ui/src/i18n/locales/pt.json | 24 ++- apps/ui/src/i18n/locales/zh-CN.json | 24 ++- 11 files changed, 519 insertions(+), 87 deletions(-) diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue index 3cd07d6..e1e4415 100644 --- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue +++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue @@ -171,6 +171,15 @@ function isDivergedRow(key: string): boolean { } const divergedCount = computed(() => templates.value.filter((t) => isDivergedRow(t.key)).length); const localCount = computed(() => templates.value.filter((t) => localEdits.has(layerEditName(t.key))).length); + +// Auto-uncheck the picker filter whenever its underlying set goes to +// zero. Without this the operator can hit Reset / Push / Sync — which +// drops the matching row out of the filter — and then be stranded on +// an empty picker with no way to navigate to another layer (the +// filter checkbox would also be disabled since divergedCount === 0). +// Catches every path that depletes the set, not just reset. +watch(divergedCount, (n) => { if (n === 0) divergedOnly.value = false; }); +watch(localCount, (n) => { if (n === 0) localOnly.value = false; }); const filteredTemplates = computed<AdminLayerTemplate[]>(() => { const q = layerSearch.value.trim().toLowerCase(); return templates.value.filter((t) => { @@ -192,6 +201,13 @@ const localEdits = useLocalTemplateEdits(); // Server-side bundled + remote content for the Reset-to / Preview editor // sources. Local (browser draft) comes from `localEdits`. const sources = useTemplateSources('layer'); +// Settled-state flag for the source pill: until the config-bundle +// fetch resolves, `hasRemote(name)` returns false for every name, so +// any pill we render reflects the boot fallback (bundled) and then +// flips to its real value when the bundle lands — a visual flash on +// every page open. The pill v-if's on `sourcesReady` so it stays +// hidden until the data is real. +const sourcesReady = computed(() => !sources.isLoading.value); const previewOverride = usePreviewOverride(); async function loadAll(): Promise<void> { @@ -214,7 +230,14 @@ async function loadAll(): Promise<void> { if (SCOPES.includes(queryScope as AdminScope)) { activeScope.value = queryScope as AdminScope; } - syncDraft(); + // Seed the editor only once the config-bundle (and hence + // `remoteAvailable` / `hasLocalDraft`) has resolved — otherwise + // syncDraft falls through to bundled and the editor visibly + // re-loads as the bundle lands. Two paths: (a) sources are + // already ready (warm cache) → run synchronously; (b) still + // loading → handed off to the corrective watcher below, which + // fires syncDraft on the false → true transition. + if (sourcesReady.value) syncDraft(); } catch (err) { error.value = err instanceof Error ? err.message : String(err); } finally { @@ -253,7 +276,14 @@ watch( * is the baseline for `dirty` (= unsaved edits since the last load/save). * Saving always writes LOCAL; you then publish LOCAL → OAP. * ─────────────────────────────────────────────────────────────────── */ -const editorSource = ref<'local' | 'bundled' | 'remote'>('bundled'); +// Remote is the canonical baseline (what the runtime menu renders for +// synced + diverged + remote-only rows). The editor opens from remote +// on every (re-)mount and stays there until the operator explicitly +// loads bundled (Reset to bundled) or saves a local draft. The default +// `'remote'` here matches what `syncDraft()` will pick on the first +// pass — pre-seeding the same value lets the source pill stay hidden +// (labelling the default would be noise). +const editorSource = ref<'local' | 'bundled' | 'remote'>('remote'); const loadedSnapshot = ref<string>(''); const editName = computed(() => layerEditName(selectedKey.value)); const hasLocalDraft = computed(() => localEdits.has(editName.value)); @@ -293,22 +323,22 @@ function loadFrom(src: 'local' | 'bundled' | 'remote'): void { editorSource.value = src; saveMsg.value = null; } -/** Seed the editor when the selected layer changes. Priority mirrors - * what the operator actually sees in the live menu / dashboards: +/** Seed the editor when the selected layer changes. Remote is the + * canonical baseline — it's what `pickLayerContent` in the runtime + * bundle serves to end users for synced / diverged / remote-only + * rows, so the editor opens from remote whenever remote is reachable. + * Priority: * 1. Local draft — unpublished in-progress edits in this browser. - * 2. Remote — when bundled and remote diverged (or the layer is - * remote-only), OAP is the source of truth and the runtime - * bundle loads it via `pickLayerContent` (bundle.ts). Showing - * bundled here would silently disagree with the live UI. - * 3. Bundled — synced rows are byte-equal anyway; bundled-fallback - * is the bundle's only source. */ + * 2. Remote — the default for every re-mount when remote exists. + * 3. Bundled — only when remote is absent (bundled-fallback). The + * operator can also hit "Reset to bundled" explicitly to swap; + * that path goes through `resetTo`, not this seed function. */ function syncDraft(): void { if (hasLocalDraft.value) { loadFrom('local'); return; } - const badge = sync.badgeFor(editName.value); - if ((badge === 'diverged' || badge === 'remote-only') && remoteAvailable.value) { + if (remoteAvailable.value) { loadFrom('remote'); return; } @@ -329,12 +359,17 @@ function persistLocal(content: AdminLayerTemplate): void { loadedSnapshot.value = JSON.stringify(content); } -// "Reset to ▾" dropdown — reload the editor from a source AND adopt it as -// the local draft (so the choice persists + Push/diff status updates). +// "Reset to ▾" dropdown — discard the current local draft and reload the +// editor from the picked source. The op-facing tooltip says "Discard +// current edits and reload …", so the action must DROP the local draft +// rather than re-stage the new content as a fresh draft (which the +// previous implementation did via persistLocal, triggering the +// TemplateConflictPrompt right after every reset). Subsequent edits in +// the editor re-create a local draft naturally on the next change. const resetDropdownOpen = ref(false); function resetTo(src: 'bundled' | 'remote'): void { loadFrom(src); - if (draft.template) persistLocal(draft.template); + localEdits.remove(editName.value); resetDropdownOpen.value = false; } @@ -387,7 +422,21 @@ function previewLive(src: 'local' | 'bundled' | 'remote'): void { window.open(href, '_blank', 'noopener'); } -watch(selectedKey, syncDraft); +watch(selectedKey, () => { + if (sourcesReady.value) syncDraft(); +}); + +// First-mount deferred sync. When the page opens on a cold cache +// `loadAll()` returns before the config-bundle has settled, so the +// initial syncDraft is skipped (see loadAll). The moment sources +// transition to ready, run syncDraft for the currently-selected +// layer — once. No clobber on later transitions: this only fires on +// the false → true edge. +watch(sourcesReady, (ready, wasReady) => { + if (ready && !wasReady && selectedKey.value) { + syncDraft(); + } +}); onMounted(loadAll); // Force-refresh the cached config bundle on mount so per-row badges // (`synced` / `diverged` / `disabled`) reflect actual OAP state. Without @@ -1599,13 +1648,29 @@ const namingTest = computed<NamingTestResult>(() => { </div> <div class="actions"> <span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span> - <!-- Where the editor content was loaded from — distinct from - the sync-status chip by the title (prefixed "from "). --> + <!-- Source pill — three visible states, one per + `editorSource`. The pill is gated on + `sourcesReady` to suppress the flash on initial + load: until the config-bundle settles we can't tell + whether the row is remote-backed or bundled-only, + so showing anything is a guess that flips moments + later. Once the bundle resolves the right pill + renders directly. --> + <span + v-if="sourcesReady && editorSource === 'local'" + class="src-tag is-local" + :title="t('Unpublished local edits — Push to publish to OAP.')" + >{{ t('from local') }}</span> + <span + v-else-if="sourcesReady && editorSource === 'bundled'" + class="src-tag is-bundled" + :title="t('Showing the shipped bundled default — Push to overwrite OAP with bundled.')" + >{{ t('from bundled') }}</span> <span - v-if="editorSource === 'local' || !isSynced" - class="src-tag" - :title="`Editing from: ${editorSource}`" - >from {{ editorSource }}</span> + v-else-if="sourcesReady && editorSource === 'remote'" + class="src-tag is-remote" + :title="t('Showing the OAP-live version. End users render the same bytes.')" + >{{ t('from remote') }}</span> <!-- Reset the editor to a source (discard current content). --> <div class="reset-dd"> <button class="sw-btn" type="button" @click="resetDropdownOpen = !resetDropdownOpen"> @@ -1614,23 +1679,32 @@ const namingTest = computed<NamingTestResult>(() => { <template v-if="resetDropdownOpen"> <div class="reset-dd-backdrop" @click="resetDropdownOpen = false" /> <div class="reset-dd-pop"> + <!-- "Reset to → Bundled" stays visible even when + the row is synced (bundled === remote). The + button is disabled in that case with a + "(synced)" tail so the operator can tell the + option still exists but there's nothing + meaningful to reset to — Bundled equals what + Remote would already give them. --> <button - v-if="!isSynced" class="reset-dd-item" type="button" - title="Discard current edits and reload the bundled (shipped) default." + :disabled="isSynced" + :title="isSynced + ? t('Bundled equals OAP-live for this row — nothing to reset to.') + : t('Discard current edits and reload the bundled (shipped) default.')" @click="resetTo('bundled')" > - Bundled + {{ t('Bundled') }}<span v-if="isSynced" class="reset-dd-suffix"> {{ t('(synced)') }}</span> </button> <button class="reset-dd-item" type="button" :disabled="!remoteAvailable" - :title="remoteAvailable ? 'Discard current edits and reload OAP\'s live version.' : 'OAP has no copy of this template yet.'" + :title="remoteAvailable ? t('Discard current edits and reload OAP\'s live version.') : t('OAP has no copy of this template yet.')" @click="resetTo('remote')" > - Remote + {{ t('Remote') }} </button> </div> </template> diff --git a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue index 353665e..81f2fd0 100644 --- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue +++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue @@ -83,6 +83,10 @@ const router = useRouter(); const localEdits = useLocalTemplateEdits(); const previewOverride = usePreviewOverride(); const sources = useTemplateSources('overview'); +// See LayerDashboardsAdmin: the source pill stays hidden until the +// config-bundle settles so the initial pill state isn't a guess that +// flips moments later. +const sourcesReady = computed(() => !sources.isLoading.value); const OV_PREFIX = 'horizon.overview.'; function summaryFrom( @@ -165,6 +169,15 @@ function isDivergedRow(id: string): boolean { } const divergedCount = computed(() => dashboards.value.filter((d) => isDivergedRow(d.id)).length); const localDraftCount = computed(() => dashboards.value.filter((d) => hasLocalDraftFor(d.id)).length); + +// Auto-uncheck the picker filter when its underlying set drops to +// zero. Without this the operator can hit Reset / Push / Sync — which +// removes the matching row from the filtered set — and end up stuck +// on an empty list with the checkbox itself disabled (since +// divergedCount / localDraftCount === 0). Same guard as the Layer +// Dashboards admin page. +watch(divergedCount, (n) => { if (n === 0) divergedOnly.value = false; }); +watch(localDraftCount, (n) => { if (n === 0) localOnly.value = false; }); const filteredDashboards = computed(() => { const q = listSearch.value.trim().toLowerCase(); return dashboards.value.filter((d) => { @@ -553,7 +566,13 @@ const isSynced = computed<boolean>( ); const draft = ref<OverviewDashboard | null>(null); -const editorSource = ref<'local' | 'bundled' | 'remote'>('bundled'); +// Remote is the canonical baseline. The editor opens from remote on +// every (re-)mount (`defaultEditorSource` returns 'remote' whenever +// remote is reachable + no local draft exists) and stays there until +// the operator explicitly hits "Reset to bundled" or saves a local +// draft. The initial ref matches so the source pill can stay hidden +// on the default path. +const editorSource = ref<'local' | 'bundled' | 'remote'>('remote'); const loadedSnapshot = ref<string>(''); function bundledContent(): OverviewDashboard | null { @@ -587,12 +606,15 @@ function persistLocal(content: OverviewDashboard): void { loadedSnapshot.value = JSON.stringify(content); } -// "Reset to ▾" dropdown — reload from a source AND adopt it as the local -// draft (so the choice persists + Push/diff status updates). +// "Reset to ▾" dropdown — discard the current local draft and reload +// the editor from the picked source. Symmetric with the layer +// dashboards editor: reset is "discard, reload", not "re-stage as a +// new draft" (which would trigger TemplateConflictPrompt right after +// every reset). Subsequent edits re-create a local draft naturally. const resetDropdownOpen = ref(false); function resetTo(src: 'bundled' | 'remote'): void { loadFrom(src); - if (draft.value) persistLocal(draft.value); + if (editName.value) localEdits.remove(editName.value); resetDropdownOpen.value = false; } @@ -667,39 +689,43 @@ const isDirty = computed<boolean>(() => ); // Which source to seed the editor from for the current selection. -// Priority mirrors what the operator sees on the live overview pages: +// Remote is the canonical baseline — it's what `pickOverviewContent` +// in the runtime bundle serves to end users — so the editor opens +// from remote on every re-mount when remote is reachable. Priority: // 1. Local draft — unpublished in-progress edits in this browser. -// 2. Remote — when bundled and remote diverged (or the row is -// remote-only), OAP is the source of truth and the runtime bundle -// loads remote via `pickOverviewContent` (bundle.ts). Loading -// bundled here silently disagrees with the live UI. -// 3. Bundled — synced rows are byte-equal anyway; bundled-fallback is -// the bundle's only source. -// 4. Last resort: remote when there's no bundled at all -// (defensive — every overview ships a bundled default today). +// 2. Remote — the default for every re-entry when remote exists. +// 3. Bundled — fallback for bundled-only overviews (no OAP row). function defaultEditorSource(): 'local' | 'bundled' | 'remote' { if (hasLocalDraft.value) return 'local'; - if (editorSource.value === 'remote' && remoteAvailable.value) return 'remote'; - const badge = editName.value ? sync.badgeFor(editName.value) : null; - if ((badge === 'diverged' || badge === 'remote-only') && remoteAvailable.value) { - return 'remote'; - } - if (bundledContent()) return 'bundled'; if (remoteAvailable.value) return 'remote'; + if (bundledContent()) return 'bundled'; return 'bundled'; } // Seed the editor when the selected overview (or a background refetch) -// changes — but never clobber unsaved keystrokes. +// changes — but never clobber unsaved keystrokes OR an operator-driven +// load (Reset to bundled / local draft). Defer the initial seed until +// `sourcesReady` so the editor doesn't visibly flip from bundled to +// remote on first mount. Once sources are ready the watcher runs +// every time the operator picks a different overview. watch( - [selectedId, () => detailQuery.data.value, hasLocalDraft, remoteAvailable], + [selectedId, () => detailQuery.data.value], () => { + if (!sourcesReady.value) return; if (isDirty.value) return; loadFrom(defaultEditorSource()); }, { immediate: true }, ); +// First-mount deferred seed. The moment sources transition to ready, +// run the seed for the currently-selected overview once. +watch(sourcesReady, (ready, wasReady) => { + if (ready && !wasReady && selectedId.value && !isDirty.value) { + loadFrom(defaultEditorSource()); + } +}); + /** Save the editor to the browser local draft. Never writes OAP. */ function onSave(): void { if (!draft.value || !editName.value) return; @@ -1058,24 +1084,26 @@ function widgetKindLabel(type: OverviewWidget['type']): string { <!-- Source / save / publish actions, right-aligned (same row as the title + tabs, mirroring the layer dashboards editor). --> <div class="ot__head-actions"> - <!-- Source pill. Mirrors the layer dashboards editor wording - ("from <source>") so operators get the same hint about - which bytes are currently in the buffer: - - local = unpublished browser draft - - remote = OAP-live (default for diverged / remote- - only rows so the editor agrees with what - the live menu renders) - - bundled = shipped default (only when the row is - bundled-fallback or the operator just - hit "Reset to bundled") - Hidden when the row is "synced" (bundled === remote) and - no local draft exists, because in that case "from - anything" is unambiguous noise. --> + <!-- Source pill — three visible states gated on + `sourcesReady` so the initial render doesn't flash + "from bundled" while the config bundle is still + resolving (see Layer Dashboards admin for the + parallel fix). --> + <span + v-if="sourcesReady && editorSource === 'local'" + class="ot__src is-local" + :title="t('Unpublished local edits — Push to publish to OAP.')" + >{{ t('from local') }}</span> + <span + v-else-if="sourcesReady && editorSource === 'bundled'" + class="ot__src is-bundled" + :title="t('Showing the shipped bundled default — Push to overwrite OAP with bundled.')" + >{{ t('from bundled') }}</span> <span - v-if="editorSource === 'local' || !isSynced" - class="ot__src" - :title="`Editing from: ${editorSource}`" - >from {{ editorSource }}</span> + v-else-if="sourcesReady && editorSource === 'remote'" + class="ot__src is-remote" + :title="t('Showing the OAP-live version. End users render the same bytes.')" + >{{ t('from remote') }}</span> <div class="reset-dd"> <button type="button" class="ot__btn" @click="resetDropdownOpen = !resetDropdownOpen"> reset to <span class="caret" :class="{ open: resetDropdownOpen }">›</span> @@ -1083,8 +1111,20 @@ function widgetKindLabel(type: OverviewWidget['type']): string { <template v-if="resetDropdownOpen"> <div class="reset-dd-backdrop" @click="resetDropdownOpen = false" /> <div class="reset-dd-pop"> - <button v-if="!isSynced" class="reset-dd-item" type="button" title="Discard current edits and reload the bundled default." @click="resetTo('bundled')">Bundled</button> - <button class="reset-dd-item" type="button" :disabled="!remoteAvailable" :title="remoteAvailable ? 'Discard current edits and reload OAP\'s live version.' : 'OAP has no copy yet.'" @click="resetTo('remote')">Remote</button> + <!-- Bundled stays visible when synced; disabled + with "(synced)" tail so the option is + recognizable but the operator knows there's + nothing meaningful to reset to. --> + <button + class="reset-dd-item" + type="button" + :disabled="isSynced" + :title="isSynced + ? t('Bundled equals OAP-live for this row — nothing to reset to.') + : t('Discard current edits and reload the bundled default.')" + @click="resetTo('bundled')" + >{{ t('Bundled') }}<span v-if="isSynced" class="reset-dd-suffix"> {{ t('(synced)') }}</span></button> + <button class="reset-dd-item" type="button" :disabled="!remoteAvailable" :title="remoteAvailable ? t('Discard current edits and reload OAP\'s live version.') : t('OAP has no copy yet.')" @click="resetTo('remote')">{{ t('Remote') }}</button> </div> </template> </div> diff --git a/apps/ui/src/features/admin/translations/TranslationsView.vue b/apps/ui/src/features/admin/translations/TranslationsView.vue index 108d64d..6cacbf6 100644 --- a/apps/ui/src/features/admin/translations/TranslationsView.vue +++ b/apps/ui/src/features/admin/translations/TranslationsView.vue @@ -503,6 +503,58 @@ const dirty = computed<boolean>(() => { const saveMsg = ref<string | null>(null); const saving = ref(false); +/* ── Editor source tracking ──────────────────────────────────────── + * Matches the Layer Dashboards + Overview Templates admin editors: + * the pill always shows one of three states (`from local` / `from + * bundled` / `from remote`) and the dropdown lets the operator reset + * to the disk-shipped overlay only ("bundled") or to whatever OAP + * currently has ("remote"). Local edits flip the pill to "from + * local"; discard flips it back to "from remote". */ +const editorSource = ref<'local' | 'bundled' | 'remote'>('remote'); + +// Switching template or target locale recomputes the source from +// whether the operator has unstaged local edits for that (name, loc). +// Don't clobber an explicitly-set source (bundled) inside the same +// locale — the watcher only fires when name/locale changes. +watch([selectedName, target], () => { + editorSource.value = hasStagedLocal.value ? 'local' : 'remote'; +}); + +/** Rebuild the draft for one (name, locale) from a specific source. + * - `remote` reproduces the default layering (disk overlay first, OAP + * overlay wins per leaf) — the canonical baseline the operator + * sees in the live UI. + * - `bundled` shows ONLY the disk-shipped overlay, ignoring OAP. Use + * this to compare against the on-disk seed without OAP overrides. */ +function rebuildDraftForLocale(name: string, loc: string, src: 'remote' | 'bundled'): void { + const eff = effective.value; + if (!eff) return; + const tplMap = { ...(draft.value[name] ?? {}) }; + delete tplMap[loc]; + draft.value = { ...draft.value, [name]: tplMap }; + const snap = fetchedOverlays.value[overlayKey(name, loc)]; + if (!snap) return; + applyOverlayToDraft(name, loc, snap.disk, eff); + if (src === 'remote') { + applyOverlayToDraft(name, loc, snap.oap, eff); + } +} + +/** Reset to dropdown — discard the current local draft and reload the + * draft from the picked source. Symmetric with the layer / overview + * editors. */ +const resetDropdownOpen = ref(false); +function resetTo(src: 'remote' | 'bundled'): void { + const name = selectedName.value; + const loc = target.value; + if (!name) return; + localEdits.remove(name, loc); + rebuildDraftForLocale(name, loc, src); + editorSource.value = src; + resetDropdownOpen.value = false; + closePanel(); +} + /** Persist the current draft to localStorage for the active locale. * Survives reloads, doesn't reach other users, doesn't touch OAP. */ function stageLocal(): void { @@ -512,6 +564,7 @@ function stageLocal(): void { if (!name) return; if (overlay === null) localEdits.remove(name, loc); else localEdits.set(name, loc, overlay); + editorSource.value = 'local'; saveMsg.value = t('Staged {locale} locally.', { locale: LOCALE_NATIVE_LABEL[loc] }); closePanel(); setTimeout(() => (saveMsg.value = null), 4000); @@ -589,17 +642,8 @@ function discardLocal(): void { const loc = target.value; if (!name) return; localEdits.remove(name, loc); - // Re-seed the active locale's draft from OAP + disk. - const tplMap = { ...(draft.value[name] ?? {}) }; - delete tplMap[loc]; - draft.value = { ...draft.value, [name]: tplMap }; - // Force the snapshot to be re-applied (it was already fetched but - // we just dropped the draft for this locale). - const snap = fetchedOverlays.value[overlayKey(name, loc)]; - if (snap && effective.value) { - applyOverlayToDraft(name, loc, snap.disk, effective.value); - applyOverlayToDraft(name, loc, snap.oap, effective.value); - } + rebuildDraftForLocale(name, loc, 'remote'); + editorSource.value = 'remote'; saveMsg.value = t('Discarded {locale} local draft.', { locale: LOCALE_NATIVE_LABEL[loc] }); closePanel(); setTimeout(() => (saveMsg.value = null), 3000); @@ -698,8 +742,59 @@ function leafLabel(segments: Array<string | number>): string { /> </label> <span class="tv__progress">{{ t('{filled} / {total} translated', { filled: filledCount, total: allFields.length }) }}</span> + <!-- Source pill — three visible states, one per `editorSource`, + matching the Layer Dashboards + Overview Templates admin + editors. `from remote` is the default (showing what OAP + currently has); after Reset to bundled the editor shows + only the disk-shipped overlay (`from bundled`); after any + local stage the pill flips to `from local`. --> + <span + v-if="editorSource === 'local'" + class="tv__src is-local" + :title="t('Unpublished local edits — Push to publish to OAP.')" + >{{ t('from local') }}</span> + <span + v-else-if="editorSource === 'bundled'" + class="tv__src is-bundled" + :title="t('Showing the shipped bundled default — Push to overwrite OAP with bundled.')" + >{{ t('from bundled') }}</span> + <span + v-else-if="editorSource === 'remote'" + class="tv__src is-remote" + :title="t('Showing the OAP-live version. End users render the same bytes.')" + >{{ t('from remote') }}</span> <div class="tv__actions"> + <!-- Reset to ▾ dropdown — matches the layer / overview + editors. Discards local edits and re-seeds the draft from + the picked source. --> + <div class="reset-dd"> + <button + type="button" + class="sw-btn" + :disabled="readOnly || !selectedName" + @click="resetDropdownOpen = !resetDropdownOpen" + > + {{ t('reset to') }} <span class="caret" :class="{ open: resetDropdownOpen }">›</span> + </button> + <template v-if="resetDropdownOpen"> + <div class="reset-dd-backdrop" @click="resetDropdownOpen = false" /> + <div class="reset-dd-pop"> + <button + class="reset-dd-item" + type="button" + :title="t('Discard local edits and reload only the disk-shipped overlay (ignore OAP overlay).')" + @click="resetTo('bundled')" + >{{ t('Bundled') }}</button> + <button + class="reset-dd-item" + type="button" + :title="t('Discard local edits and reload from the OAP overlay row.')" + @click="resetTo('remote')" + >{{ t('Remote') }}</button> + </div> + </template> + </div> <button v-if="hasStagedLocal" type="button" @@ -825,7 +920,54 @@ function leafLabel(segments: Array<string | number>): string { padding: 3px 8px; background: var(--sw-bg-2); border-radius: 3px; font-variant-numeric: tabular-nums; } -.tv__actions { margin-left: auto; display: inline-flex; gap: 6px; } +/* Source pill ("from local") — same vocabulary as the layer + overview + * editors so operators read the same chip across all three admin pages. */ +.tv__src { + font-size: var(--sw-fs-xs); + font-weight: var(--sw-fw-semibold); + letter-spacing: var(--sw-ls-caps); + text-transform: uppercase; + color: var(--sw-warn); + padding: 3px 8px; + background: var(--sw-warn-soft); + border-radius: 3px; +} +.tv__actions { margin-left: auto; display: inline-flex; gap: 6px; align-items: center; } + +/* Reset-to dropdown — vocabulary matches the layer + overview admin + * editors so the affordance reads the same across all three pages. */ +.reset-dd { position: relative; } +.reset-dd .caret { + display: inline-block; margin-left: 4px; + transition: transform 0.1s ease; +} +.reset-dd .caret.open { transform: rotate(90deg); } +.reset-dd-backdrop { + position: fixed; inset: 0; z-index: 50; +} +.reset-dd-pop { + position: absolute; top: calc(100% + 4px); right: 0; + z-index: 51; + min-width: 160px; + background: var(--sw-bg-1); + border: 1px solid var(--sw-line-2); + border-radius: 4px; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); + padding: 4px; + display: flex; flex-direction: column; gap: 2px; +} +.reset-dd-item { + text-align: left; + padding: 6px 10px; + background: transparent; + border: 0; + border-radius: 3px; + color: var(--sw-fg-1); + font-size: var(--sw-fs-sm); + cursor: pointer; +} +.reset-dd-item:hover:not(:disabled) { background: var(--sw-bg-2); color: var(--sw-fg-0); } +.reset-dd-item:disabled { opacity: 0.4; cursor: not-allowed; } .tv__msg { padding: 6px 10px; font-size: 12px; color: var(--sw-fg-2); background: var(--sw-bg-1); border: 1px solid var(--sw-line-2); border-radius: 4px; diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json index aef836c..af41a71 100644 --- a/apps/ui/src/i18n/locales/de.json +++ b/apps/ui/src/i18n/locales/de.json @@ -1157,5 +1157,27 @@ "Window exceeds {h}h cap": "Fenster überschreitet Obergrenze von {h} h", "last {window}": "letzte {window}", "Metrics Analysis Language - …": "Metrics Analysis Language - …", - "Operate · OAP configuration": "Betrieb · OAP-Konfiguration" + "Operate · OAP configuration": "Betrieb · OAP-Konfiguration", + "from {source}": "aus {source}", + "local": "lokal", + "Local": "Lokal", + "Editor buffer differs from OAP — not the live version.": "Editor-Inhalt weicht von OAP ab — nicht die Live-Version.", + "from local": "aus lokal", + "from bundled": "aus mitgeliefert", + "(synced)": "(synchron)", + "Unpublished local edits — Push to publish to OAP.": "Unveröffentlichte lokale Änderungen — auf OAP per Push veröffentlichen.", + "Showing the shipped bundled default — Push to overwrite OAP with bundled.": "Zeigt den mitgelieferten Standard — per Push OAP mit dem mitgelieferten Stand überschreiben.", + "Bundled equals OAP-live for this row — nothing to reset to.": "Mitgeliefert entspricht der OAP-Live-Version — nichts zum Zurücksetzen.", + "Discard current edits and reload the bundled (shipped) default.": "Aktuelle Änderungen verwerfen und mitgelieferten Standard neu laden.", + "Discard current edits and reload the bundled default.": "Aktuelle Änderungen verwerfen und mitgelieferten Standard neu laden.", + "Discard current edits and reload OAP's live version.": "Aktuelle Änderungen verwerfen und OAP-Live-Version neu laden.", + "OAP has no copy of this template yet.": "OAP hat noch keine Kopie dieses Templates.", + "OAP has no copy yet.": "OAP hat noch keine Kopie.", + "Bundled": "Mitgeliefert", + "Remote": "Remote", + "from remote": "aus remote", + "Showing the OAP-live version. End users render the same bytes.": "Zeigt die OAP-Live-Version — exakt was Endnutzer sehen.", + "reset to": "zurücksetzen auf", + "Discard local edits and reload only the disk-shipped overlay (ignore OAP overlay).": "Lokale Änderungen verwerfen und nur das mitgelieferte Disk-Overlay neu laden (OAP-Overlay ignorieren).", + "Discard local edits and reload from the OAP overlay row.": "Lokale Änderungen verwerfen und aus der OAP-Overlay-Zeile neu laden." } diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index 3cc5141..7f478ce 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -1157,5 +1157,27 @@ "Window exceeds {h}h cap": "Window exceeds {h}h cap", "last {window}": "last {window}", "Metrics Analysis Language - …": "Metrics Analysis Language - …", - "Operate · OAP configuration": "Operate · OAP configuration" + "Operate · OAP configuration": "Operate · OAP configuration", + "from {source}": "from {source}", + "local": "local", + "Local": "Local", + "Editor buffer differs from OAP — not the live version.": "Editor buffer differs from OAP — not the live version.", + "from local": "from local", + "from bundled": "from bundled", + "(synced)": "(synced)", + "Unpublished local edits — Push to publish to OAP.": "Unpublished local edits — Push to publish to OAP.", + "Showing the shipped bundled default — Push to overwrite OAP with bundled.": "Showing the shipped bundled default — Push to overwrite OAP with bundled.", + "Bundled equals OAP-live for this row — nothing to reset to.": "Bundled equals OAP-live for this row — nothing to reset to.", + "Discard current edits and reload the bundled (shipped) default.": "Discard current edits and reload the bundled (shipped) default.", + "Discard current edits and reload the bundled default.": "Discard current edits and reload the bundled default.", + "Discard current edits and reload OAP's live version.": "Discard current edits and reload OAP's live version.", + "OAP has no copy of this template yet.": "OAP has no copy of this template yet.", + "OAP has no copy yet.": "OAP has no copy yet.", + "Bundled": "Bundled", + "Remote": "Remote", + "from remote": "from remote", + "Showing the OAP-live version. End users render the same bytes.": "Showing the OAP-live version. End users render the same bytes.", + "reset to": "reset to", + "Discard local edits and reload only the disk-shipped overlay (ignore OAP overlay).": "Discard local edits and reload only the disk-shipped overlay (ignore OAP overlay).", + "Discard local edits and reload from the OAP overlay row.": "Discard local edits and reload from the OAP overlay row." } diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json index 7f9a23d..45bad9b 100644 --- a/apps/ui/src/i18n/locales/es.json +++ b/apps/ui/src/i18n/locales/es.json @@ -1157,5 +1157,27 @@ "Window exceeds {h}h cap": "La ventana supera el tope de {h}h", "last {window}": "últimos {window}", "Metrics Analysis Language - …": "Metrics Analysis Language - …", - "Operate · OAP configuration": "Operar · Configuración de OAP" + "Operate · OAP configuration": "Operar · Configuración de OAP", + "from {source}": "desde {source}", + "local": "local", + "Local": "Local", + "Editor buffer differs from OAP — not the live version.": "El buffer del editor difiere de OAP — no es la versión en vivo.", + "from local": "desde local", + "from bundled": "desde bundled", + "(synced)": "(sincronizado)", + "Unpublished local edits — Push to publish to OAP.": "Cambios locales sin publicar — usa Push para publicar en OAP.", + "Showing the shipped bundled default — Push to overwrite OAP with bundled.": "Mostrando el valor predeterminado bundled — Push sobrescribe OAP con bundled.", + "Bundled equals OAP-live for this row — nothing to reset to.": "En esta fila, bundled coincide con OAP-live — no hay nada que restablecer.", + "Discard current edits and reload the bundled (shipped) default.": "Descarta los cambios actuales y recarga el valor predeterminado bundled (de fábrica).", + "Discard current edits and reload the bundled default.": "Descarta los cambios actuales y recarga el valor predeterminado bundled.", + "Discard current edits and reload OAP's live version.": "Descarta los cambios actuales y recarga la versión en vivo de OAP.", + "OAP has no copy of this template yet.": "OAP aún no tiene una copia de esta plantilla.", + "OAP has no copy yet.": "OAP aún no tiene una copia.", + "Bundled": "Bundled", + "Remote": "Remote", + "from remote": "desde remote", + "Showing the OAP-live version. End users render the same bytes.": "Mostrando la versión en vivo de OAP — los usuarios finales ven los mismos bytes.", + "reset to": "restablecer a", + "Discard local edits and reload only the disk-shipped overlay (ignore OAP overlay).": "Descarta los cambios locales y recarga solo el overlay de disco (ignora el overlay de OAP).", + "Discard local edits and reload from the OAP overlay row.": "Descarta los cambios locales y recarga desde la fila de overlay de OAP." } diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json index 5471b28..cd03348 100644 --- a/apps/ui/src/i18n/locales/fr.json +++ b/apps/ui/src/i18n/locales/fr.json @@ -1157,5 +1157,27 @@ "Window exceeds {h}h cap": "La fenêtre dépasse le plafond de {h} h", "last {window}": "dernier(s) {window}", "Metrics Analysis Language - …": "Metrics Analysis Language - …", - "Operate · OAP configuration": "Exploitation · Configuration OAP" + "Operate · OAP configuration": "Exploitation · Configuration OAP", + "from {source}": "depuis {source}", + "local": "local", + "Local": "Local", + "Editor buffer differs from OAP — not the live version.": "Le contenu de l'éditeur diffère d'OAP — ce n'est pas la version en ligne.", + "from local": "depuis local", + "from bundled": "depuis bundled", + "(synced)": "(synchronisé)", + "Unpublished local edits — Push to publish to OAP.": "Modifications locales non publiées — Push pour publier sur OAP.", + "Showing the shipped bundled default — Push to overwrite OAP with bundled.": "Affiche la valeur par défaut bundled livrée — Push pour écraser OAP avec bundled.", + "Bundled equals OAP-live for this row — nothing to reset to.": "Pour cette ligne, bundled est identique à la version OAP en ligne — rien à réinitialiser.", + "Discard current edits and reload the bundled (shipped) default.": "Abandonner les modifications actuelles et recharger la valeur par défaut bundled (livrée).", + "Discard current edits and reload the bundled default.": "Abandonner les modifications actuelles et recharger la valeur par défaut bundled.", + "Discard current edits and reload OAP's live version.": "Abandonner les modifications actuelles et recharger la version OAP en ligne.", + "OAP has no copy of this template yet.": "OAP n'a pas encore de copie de ce template.", + "OAP has no copy yet.": "OAP n'a pas encore de copie.", + "Bundled": "Bundled", + "Remote": "Remote", + "from remote": "depuis remote", + "Showing the OAP-live version. End users render the same bytes.": "Affiche la version OAP en ligne — exactement ce que voient les utilisateurs finaux.", + "reset to": "réinitialiser à", + "Discard local edits and reload only the disk-shipped overlay (ignore OAP overlay).": "Abandonner les modifications locales et recharger uniquement l'overlay livré sur disque (ignorer l'overlay OAP).", + "Discard local edits and reload from the OAP overlay row.": "Abandonner les modifications locales et recharger depuis la ligne d'overlay OAP." } diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json index 0864c8a..d5aefd9 100644 --- a/apps/ui/src/i18n/locales/ja.json +++ b/apps/ui/src/i18n/locales/ja.json @@ -1157,5 +1157,27 @@ "Window exceeds {h}h cap": "範囲が {h} 時間の上限を超えています", "last {window}": "直近 {window}", "Metrics Analysis Language - …": "Metrics Analysis Language - …", - "Operate · OAP configuration": "運用 · OAP 設定" + "Operate · OAP configuration": "運用 · OAP 設定", + "from {source}": "{source} から", + "local": "ローカル", + "Local": "ローカル", + "Editor buffer differs from OAP — not the live version.": "エディタの内容は OAP と異なります — ライブ版ではありません。", + "from local": "ローカルから", + "from bundled": "バンドルから", + "(synced)": "(同期済み)", + "Unpublished local edits — Push to publish to OAP.": "未公開のローカル編集 — Push で OAP に公開します。", + "Showing the shipped bundled default — Push to overwrite OAP with bundled.": "同梱されたバンドルのデフォルトを表示中 — Push でバンドルを OAP に上書きします。", + "Bundled equals OAP-live for this row — nothing to reset to.": "この行はバンドルと OAP のライブが一致しています — リセット先がありません。", + "Discard current edits and reload the bundled (shipped) default.": "現在の編集を破棄し、同梱されたバンドルのデフォルトを再読み込みします。", + "Discard current edits and reload the bundled default.": "現在の編集を破棄し、バンドルのデフォルトを再読み込みします。", + "Discard current edits and reload OAP's live version.": "現在の編集を破棄し、OAP のライブ版を再読み込みします。", + "OAP has no copy of this template yet.": "OAP にはまだこのテンプレートのコピーがありません。", + "OAP has no copy yet.": "OAP にはまだコピーがありません。", + "Bundled": "バンドル", + "Remote": "リモート", + "from remote": "リモートから", + "Showing the OAP-live version. End users render the same bytes.": "OAP のライブ版を表示中 — エンドユーザーが見るものと同じです。", + "reset to": "リセット先", + "Discard local edits and reload only the disk-shipped overlay (ignore OAP overlay).": "ローカル編集を破棄し、同梱のディスクオーバーレイのみを再読み込み(OAP オーバーレイは無視)。", + "Discard local edits and reload from the OAP overlay row.": "ローカル編集を破棄し、OAP オーバーレイ行から再読み込み。" } diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json index 3d08e58..6f7613b 100644 --- a/apps/ui/src/i18n/locales/ko.json +++ b/apps/ui/src/i18n/locales/ko.json @@ -1157,5 +1157,27 @@ "Window exceeds {h}h cap": "범위가 {h}시간 상한을 초과합니다", "last {window}": "최근 {window}", "Metrics Analysis Language - …": "Metrics Analysis Language - …", - "Operate · OAP configuration": "운영 · OAP 구성" + "Operate · OAP configuration": "운영 · OAP 구성", + "from {source}": "{source}에서", + "local": "로컬", + "Local": "로컬", + "Editor buffer differs from OAP — not the live version.": "에디터 내용이 OAP와 다릅니다 — 라이브 버전이 아닙니다.", + "from local": "로컬에서", + "from bundled": "번들에서", + "(synced)": "(동기화됨)", + "Unpublished local edits — Push to publish to OAP.": "게시되지 않은 로컬 편집 — Push로 OAP에 게시합니다.", + "Showing the shipped bundled default — Push to overwrite OAP with bundled.": "번들된 기본값을 표시 중 — Push로 번들을 OAP에 덮어씁니다.", + "Bundled equals OAP-live for this row — nothing to reset to.": "이 행은 번들과 OAP 라이브가 동일 — 재설정할 대상이 없습니다.", + "Discard current edits and reload the bundled (shipped) default.": "현재 편집을 버리고 번들(배포) 기본값을 다시 불러옵니다.", + "Discard current edits and reload the bundled default.": "현재 편집을 버리고 번들 기본값을 다시 불러옵니다.", + "Discard current edits and reload OAP's live version.": "현재 편집을 버리고 OAP 라이브 버전을 다시 불러옵니다.", + "OAP has no copy of this template yet.": "OAP에 아직 이 템플릿 복사본이 없습니다.", + "OAP has no copy yet.": "OAP에 아직 복사본이 없습니다.", + "Bundled": "번들", + "Remote": "원격", + "from remote": "원격에서", + "Showing the OAP-live version. End users render the same bytes.": "OAP 라이브 버전 표시 중 — 최종 사용자가 보는 것과 동일합니다.", + "reset to": "재설정 대상", + "Discard local edits and reload only the disk-shipped overlay (ignore OAP overlay).": "로컬 편집을 버리고 디스크에 동봉된 오버레이만 다시 불러옵니다(OAP 오버레이 무시).", + "Discard local edits and reload from the OAP overlay row.": "로컬 편집을 버리고 OAP 오버레이 행에서 다시 불러옵니다." } diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json index 52a347a..e12ea9a 100644 --- a/apps/ui/src/i18n/locales/pt.json +++ b/apps/ui/src/i18n/locales/pt.json @@ -1157,5 +1157,27 @@ "Window exceeds {h}h cap": "A janela excede o limite de {h}h", "last {window}": "últimos {window}", "Metrics Analysis Language - …": "Metrics Analysis Language - …", - "Operate · OAP configuration": "Operar · Configuração do OAP" + "Operate · OAP configuration": "Operar · Configuração do OAP", + "from {source}": "a partir de {source}", + "local": "local", + "Local": "Local", + "Editor buffer differs from OAP — not the live version.": "O conteúdo do editor difere do OAP — não é a versão em produção.", + "from local": "a partir de local", + "from bundled": "a partir de bundled", + "(synced)": "(sincronizado)", + "Unpublished local edits — Push to publish to OAP.": "Edições locais não publicadas — use Push para publicar no OAP.", + "Showing the shipped bundled default — Push to overwrite OAP with bundled.": "Exibindo o padrão bundled — Push sobrescreve o OAP com bundled.", + "Bundled equals OAP-live for this row — nothing to reset to.": "Nesta linha, bundled coincide com a versão em produção do OAP — nada para redefinir.", + "Discard current edits and reload the bundled (shipped) default.": "Descartar edições atuais e recarregar o padrão bundled (de fábrica).", + "Discard current edits and reload the bundled default.": "Descartar edições atuais e recarregar o padrão bundled.", + "Discard current edits and reload OAP's live version.": "Descartar edições atuais e recarregar a versão em produção do OAP.", + "OAP has no copy of this template yet.": "OAP ainda não tem uma cópia deste template.", + "OAP has no copy yet.": "OAP ainda não tem uma cópia.", + "Bundled": "Bundled", + "Remote": "Remote", + "from remote": "a partir de remote", + "Showing the OAP-live version. End users render the same bytes.": "Exibindo a versão em produção do OAP — os usuários finais veem exatamente isto.", + "reset to": "redefinir para", + "Discard local edits and reload only the disk-shipped overlay (ignore OAP overlay).": "Descartar edições locais e recarregar apenas o overlay do disco (ignorar o overlay do OAP).", + "Discard local edits and reload from the OAP overlay row.": "Descartar edições locais e recarregar a partir da linha de overlay do OAP." } diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json index 86ce28c..bc44031 100644 --- a/apps/ui/src/i18n/locales/zh-CN.json +++ b/apps/ui/src/i18n/locales/zh-CN.json @@ -1157,5 +1157,27 @@ "Window exceeds {h}h cap": "窗口超过 {h} 小时上限", "last {window}": "最近 {window}", "Metrics Analysis Language - …": "Metrics Analysis Language - …", - "Operate · OAP configuration": "运维 · OAP 配置" + "Operate · OAP configuration": "运维 · OAP 配置", + "from {source}": "来自 {source}", + "local": "本地", + "Local": "本地", + "Editor buffer differs from OAP — not the live version.": "编辑器内容与 OAP 不同 — 不是线上版本。", + "from local": "来自本地", + "from bundled": "来自内置", + "(synced)": "(已同步)", + "Unpublished local edits — Push to publish to OAP.": "未发布的本地编辑 — 推送以发布到 OAP。", + "Showing the shipped bundled default — Push to overwrite OAP with bundled.": "当前显示内置默认值 — 推送会用内置版覆盖 OAP。", + "Bundled equals OAP-live for this row — nothing to reset to.": "该行的内置版与 OAP 实时版本一致 — 无需重置。", + "Discard current edits and reload the bundled (shipped) default.": "丢弃当前编辑,重新加载内置(发行)默认值。", + "Discard current edits and reload the bundled default.": "丢弃当前编辑,重新加载内置默认值。", + "Discard current edits and reload OAP's live version.": "丢弃当前编辑,重新加载 OAP 实时版本。", + "OAP has no copy of this template yet.": "OAP 还没有此模板的副本。", + "OAP has no copy yet.": "OAP 还没有副本。", + "Bundled": "内置", + "Remote": "远端", + "from remote": "来自远端", + "Showing the OAP-live version. End users render the same bytes.": "显示 OAP 实时版本 — 与终端用户看到的内容一致。", + "reset to": "重置为", + "Discard local edits and reload only the disk-shipped overlay (ignore OAP overlay).": "丢弃本地编辑,只重新加载随包的磁盘覆盖层(忽略 OAP 覆盖层)。", + "Discard local edits and reload from the OAP overlay row.": "丢弃本地编辑,从 OAP 覆盖行重新加载。" }
