+
+
-
- {$i18n.t('Original')} {$i18n.t('Translation')}
-
- {#each keys.slice(page * 50, (page + 1) * 50) as key (key)}
-
-
-
{key}
- {#if source[key] && source[key] !== key}
- {source[key]}
-
{/if}
+
+ {#each keys.slice(page * 50, (page + 1) * 50) as key (key)}
+
+
+
+ {source[key] || key}
+
+
+
+
+ {#if !readOnly && Object.hasOwn(value ?? {}, key)}
+
+ reset(key)}>
+
+ {/if}
+
-
-
reset(key)}>
-
- {/each}
- {#if !keys.length}
- {$i18n.t('No results found')}
-
{/if}
-
-
-
{page + 1} / {pages}
-
page--}>
-
= pages}
- on:click={() => page++}>
+ {:else}
+
+ {$i18n.t('No results found')}
+
+ {/each}
+
+ {#if pages > 1}
+
+ {page + 1} / {pages}
+ page--}>
+ = pages}
+ on:click={() => page++}>
+
+ {/if}
diff --git a/src/lib/components/common/diffEditor.ts b/src/lib/components/common/diffEditor.ts
new file mode 100644
index 0000000000..699853e48a
--- /dev/null
+++ b/src/lib/components/common/diffEditor.ts
@@ -0,0 +1,76 @@
+import type { ChangeSpec, Text } from '@codemirror/state';
+import type { Chunk, DiffConfig } from '@codemirror/merge';
+
+// A scanLimit also gives up based on document length, even for sparse, tiny edits.
+export const diffEditorConfig: DiffConfig = { timeout: 100 };
+
+export type MergeDirection = 'to-working' | 'to-proposal';
+
+export function changedLineCounts(a: Text, b: Text, chunks: readonly Chunk[]) {
+ const count = (doc: Text, side: 'A' | 'B') => {
+ let total = 0;
+ let lastLine = 0;
+ for (const chunk of chunks) {
+ for (const change of chunk.changes) {
+ const from = chunk[`from${side}`] + change[`from${side}`];
+ const to = chunk[`from${side}`] + change[`to${side}`];
+ if (from === to) continue;
+ const first = doc.lineAt(from).number;
+ const last = doc.lineAt(to - 1).number;
+ total += Math.max(0, last - Math.max(first - 1, lastLine));
+ lastLine = last;
+ }
+ }
+ return total;
+ };
+ return { removed: count(a, 'A'), added: count(b, 'B') };
+}
+
+export function chunkOverview(a: Text, b: Text, chunks: readonly Chunk[]) {
+ const range = (doc: Text, from: number, to: number) => ({
+ from: (doc.lineAt(Math.min(from, doc.length)).number - 1) / doc.lines,
+ to: doc.lineAt(Math.min(Math.max(from, to - 1), doc.length)).number / doc.lines
+ });
+ return chunks.map((chunk) => ({
+ a: range(a, chunk.fromA, chunk.toA),
+ b: range(b, chunk.fromB, chunk.toB)
+ }));
+}
+
+export function nearestOverviewChunk(
+ ranges: ReturnType
,
+ side: 'a' | 'b',
+ position: number
+) {
+ let nearest = -1;
+ let distance = Infinity;
+ ranges.forEach((range, index) => {
+ const delta = Math.max(range[side].from - position, position - range[side].to, 0);
+ if (delta < distance) {
+ nearest = index;
+ distance = delta;
+ }
+ });
+ return nearest;
+}
+
+export function copyChunk(a: Text, b: Text, chunk: Chunk, direction: MergeDirection): ChangeSpec {
+ const toWorking = direction === 'to-working';
+ const source = toWorking ? b : a;
+ const destination = toWorking ? a : b;
+ const [from, to, sourceFrom, sourceTo] = toWorking
+ ? [chunk.fromA, chunk.toA, chunk.fromB, chunk.toB]
+ : [chunk.fromB, chunk.toB, chunk.fromA, chunk.toA];
+ // Chunk ends include a virtual newline at EOF; only insert it between actual lines.
+ let insert = source.sliceString(sourceFrom, Math.max(sourceFrom, sourceTo - 1));
+ if (sourceFrom !== sourceTo && to <= destination.length) insert += '\n';
+ return { from, to: Math.min(to, destination.length), insert };
+}
+
+export function selectedChunk(chunks: readonly Chunk[], side: 'a' | 'b', position: number) {
+ return chunks.findIndex((chunk) => {
+ const from = side === 'a' ? chunk.fromA : chunk.fromB;
+ const to = side === 'a' ? chunk.toA : chunk.toB;
+ return position >= from && (position < to || position === from);
+ });
+}
diff --git a/src/lib/components/common/diffEditorAlignment.ts b/src/lib/components/common/diffEditorAlignment.ts
new file mode 100644
index 0000000000..129259ef6f
--- /dev/null
+++ b/src/lib/components/common/diffEditorAlignment.ts
@@ -0,0 +1,170 @@
+import { StateEffect, StateField, type Text } from '@codemirror/state';
+import {
+ BlockType,
+ Decoration,
+ EditorView,
+ WidgetType,
+ type DecorationSet
+} from '@codemirror/view';
+import type { Chunk } from '@codemirror/merge';
+
+export function pairedLinePosition(a: Text, b: Text, chunks: readonly Chunk[], position: number) {
+ let offset = 0;
+ for (const chunk of chunks) {
+ if (position < chunk.fromA) break;
+ if (position < chunk.toA) {
+ const row = a.lineAt(position).number - a.lineAt(chunk.fromA).number;
+ const start = b.lineAt(Math.min(chunk.fromB, b.length)).number;
+ const end = b.lineAt(Math.min(Math.max(chunk.fromB, chunk.toB - 1), b.length)).number;
+ return b.line(Math.min(start + row, end)).from;
+ }
+ offset = chunk.toB - chunk.toA;
+ }
+ return Math.max(0, Math.min(position + offset, b.length));
+}
+
+class RowSpacer extends WidgetType {
+ constructor(readonly height: number) {
+ super();
+ }
+ eq(other: RowSpacer) {
+ return Math.abs(this.height - other.height) <= 1;
+ }
+ toDOM() {
+ const element = document.createElement('div');
+ element.className = 'cm-mergeSpacer cm-diff-rowSpacer';
+ element.style.height = `${this.height}px`;
+ return element;
+ }
+ get estimatedHeight() {
+ return this.height;
+ }
+}
+const setSpacers = StateEffect.define();
+export const lineAlignment = StateField.define({
+ create: () => Decoration.none,
+ update: (value, transaction) => {
+ for (const effect of transaction.effects) if (effect.is(setSpacers)) return effect.value;
+ return transaction.docChanged ? Decoration.none : value;
+ },
+ provide: (field) => EditorView.decorations.from(field)
+});
+
+export function clearLineAlignment(a: EditorView, b: EditorView) {
+ let changed = false;
+ for (const view of [a, b]) {
+ if (view.state.field(lineAlignment).size) {
+ view.dispatch({ effects: setSpacers.of(Decoration.none) });
+ changed = true;
+ }
+ }
+ return changed;
+}
+
+// Only remeasure paired rows present in both virtualized viewports. Recomputing
+// offscreen spacers from estimated heights would move already aligned content.
+export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly Chunk[]) {
+ const previous = (view: EditorView) => {
+ const result = new Map();
+ for (let cursor = view.state.field(lineAlignment).iter(); cursor.value; cursor.next())
+ result.set(cursor.from, cursor.value.spec.widget.height);
+ return result;
+ };
+ const oldA = previous(a),
+ oldB = previous(b);
+ const nextA = new Map(oldA),
+ nextB = new Map(oldB);
+ let offsetA = 0,
+ offsetB = 0;
+ const textTop = (view: EditorView, pos: number) => {
+ const block = view.lineBlockAt(pos);
+ const text = Array.isArray(block.type)
+ ? block.type.find((part) => part.type === BlockType.Text && part.from === pos)
+ : block.type === BlockType.Text
+ ? block
+ : null;
+ return text?.from === pos ? text.top : null;
+ };
+ const align = (posA: number, posB: number) => {
+ if (
+ posA < a.viewport.from ||
+ posB < b.viewport.from ||
+ posA > a.viewport.to ||
+ posB > b.viewport.to
+ )
+ return;
+ const topA = textTop(a, posA),
+ topB = textTop(b, posB);
+ if (topA === null || topB === null) return;
+ const previousA = oldA.get(posA) ?? 0,
+ previousB = oldB.get(posB) ?? 0;
+ const difference = topA - previousA + offsetA - topB + previousB - offsetB;
+ const sizeA = difference < -1 ? -difference : 0,
+ sizeB = difference > 1 ? difference : 0;
+ nextA.delete(posA);
+ nextB.delete(posB);
+ if (sizeA) nextA.set(posA, sizeA);
+ if (sizeB) nextB.set(posB, sizeB);
+ offsetA += sizeA - previousA;
+ offsetB += sizeB - previousB;
+ };
+ let fromA = 0,
+ fromB = 0;
+ for (let index = 0; index <= chunks.length; index++) {
+ const chunk = chunks[index];
+ const endA = chunk ? chunk.fromA : a.state.doc.length + 1;
+ let posA = Math.max(
+ fromA,
+ a.state.doc.lineAt(
+ Math.min(
+ a.state.doc.length,
+ Math.max(fromA, a.viewport.from, fromA + b.viewport.from - fromB)
+ )
+ ).from
+ );
+ for (; posA < endA && posA <= a.viewport.to; posA = a.state.doc.lineAt(posA).to + 1) {
+ const posB = fromB + posA - fromA;
+ if (posB > b.viewport.to) break;
+ align(posA, posB);
+ }
+ if (!chunk) break;
+ if (chunk.fromA < chunk.toA && chunk.fromB < chunk.toB) {
+ const lineA = a.state.doc.lineAt(chunk.fromA).number,
+ lineB = b.state.doc.lineAt(chunk.fromB).number;
+ let row = Math.max(
+ 0,
+ a.state.doc.lineAt(a.viewport.from).number - lineA,
+ b.state.doc.lineAt(b.viewport.from).number - lineB
+ );
+ while (lineA + row <= a.state.doc.lines && lineB + row <= b.state.doc.lines) {
+ const posA = a.state.doc.line(lineA + row).from,
+ posB = b.state.doc.line(lineB + row).from;
+ if (posA >= chunk.toA || posB >= chunk.toB || posA > a.viewport.to || posB > b.viewport.to)
+ break;
+ align(posA, posB);
+ row++;
+ }
+ }
+ fromA = chunk.toA;
+ fromB = chunk.toB;
+ }
+ const apply = (view: EditorView, old: Map, next: Map) => {
+ if (
+ old.size === next.size &&
+ [...next].every(([pos, size]) => Math.abs((old.get(pos) ?? 0) - size) <= 1)
+ )
+ return;
+ view.dispatch({
+ effects: setSpacers.of(
+ Decoration.set(
+ [...next].map(([pos, height]) =>
+ Decoration.widget({ widget: new RowSpacer(height), block: true, side: -1 }).range(pos)
+ ),
+ true
+ )
+ )
+ });
+ };
+ apply(a, oldA, nextA);
+ apply(b, oldB, nextB);
+}
diff --git a/src/lib/components/icons/BellSlash.svelte b/src/lib/components/icons/BellSlash.svelte
new file mode 100644
index 0000000000..343a22fb21
--- /dev/null
+++ b/src/lib/components/icons/BellSlash.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/src/lib/components/layout/SharedChatsModal.svelte b/src/lib/components/layout/SharedChatsModal.svelte
index 65e79a5db7..9fbdeb17a8 100644
--- a/src/lib/components/layout/SharedChatsModal.svelte
+++ b/src/lib/components/layout/SharedChatsModal.svelte
@@ -157,7 +157,7 @@
{
showUnshareAllConfirmDialog = true;
diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte
index d3058fe5b5..e9b2fccf20 100644
--- a/src/lib/components/layout/Sidebar.svelte
+++ b/src/lib/components/layout/Sidebar.svelte
@@ -654,6 +654,12 @@
}
});
+ onMount(() => {
+ const socketInstance = $socket;
+ socketInstance?.on('connect', initChannels);
+ return () => socketInstance?.off('connect', initChannels);
+ });
+
onMount(async () => {
try {
const width = Number(localStorage.getItem('sidebarWidth'));
@@ -716,11 +722,6 @@
}
const socketInstance = $socket;
- const scheduleChannelRefresh = () => {
- socketInstance?.off('connect', initChannels);
- socketInstance?.once('connect', initChannels);
- };
- socketInstance?.on('access:updated', scheduleChannelRefresh);
socketInstance?.on('events', chatActiveEventHandler);
socketInstance?.on('connect', refreshChatRows);
@@ -760,8 +761,6 @@
dropZone.removeEventListener('dragleave', onDragLeave);
}
- socketInstance?.off('access:updated', scheduleChannelRefresh);
- socketInstance?.off('connect', initChannels);
socketInstance?.off('events', chatActiveEventHandler);
socketInstance?.off('connect', refreshChatRows);
diff --git a/src/lib/components/layout/Sidebar/ChannelItem.svelte b/src/lib/components/layout/Sidebar/ChannelItem.svelte
index 7cc786d2cc..eda4ede8cb 100644
--- a/src/lib/components/layout/Sidebar/ChannelItem.svelte
+++ b/src/lib/components/layout/Sidebar/ChannelItem.svelte
@@ -10,12 +10,12 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import ChannelModal from './ChannelModal.svelte';
+ import ChannelMenu from '$lib/components/channel/ChannelMenu.svelte';
+ import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte';
import Emoji from '$lib/components/common/Emoji.svelte';
import HashtagIcon from '$lib/components/icons/Hashtag.svelte';
import LockIcon from '$lib/components/icons/Lock.svelte';
- import SettingsIcon from '$lib/components/icons/Settings.svelte';
import UserIcon from './icons/User.svelte';
- import XMarkIcon from './icons/XMark.svelte';
export let onUpdate: Function = () => {};
@@ -80,16 +80,56 @@