@dxos/react-ui-feed 0.11.1
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/LICENSE +105 -0
- package/dist/lib/chunk-src.mjs +2228 -0
- package/dist/lib/chunk-src.mjs.map +1 -0
- package/dist/lib/index.mjs +2 -0
- package/dist/lib/testing.mjs +1923 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts +14 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts +24 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/block.stories.d.ts +21 -0
- package/dist/types/src/components/Block/block.stories.d.ts.map +1 -0
- package/dist/types/src/components/Block/construction.d.ts +36 -0
- package/dist/types/src/components/Block/construction.d.ts.map +1 -0
- package/dist/types/src/components/Block/extensions.d.ts +23 -0
- package/dist/types/src/components/Block/extensions.d.ts.map +1 -0
- package/dist/types/src/components/Block/highlight.d.ts +14 -0
- package/dist/types/src/components/Block/highlight.d.ts.map +1 -0
- package/dist/types/src/components/Block/index.d.ts +6 -0
- package/dist/types/src/components/Block/index.d.ts.map +1 -0
- package/dist/types/src/components/Block/selection-group.d.ts +21 -0
- package/dist/types/src/components/Block/selection-group.d.ts.map +1 -0
- package/dist/types/src/components/Block/widget-state.d.ts +33 -0
- package/dist/types/src/components/Block/widget-state.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts +129 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts +47 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/index.d.ts +3 -0
- package/dist/types/src/components/MessageList/index.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/position-log.d.ts +73 -0
- package/dist/types/src/components/MessageList/position-log.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts +28 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts +21 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/index.d.ts +2 -0
- package/dist/types/src/components/Minimap/index.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.d.ts +46 -0
- package/dist/types/src/components/Outline/Outline.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts +37 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts.map +1 -0
- package/dist/types/src/components/Outline/index.d.ts +2 -0
- package/dist/types/src/components/Outline/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +5 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/debug/Debug.d.ts +17 -0
- package/dist/types/src/debug/Debug.d.ts.map +1 -0
- package/dist/types/src/debug/Stats.d.ts +30 -0
- package/dist/types/src/debug/Stats.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.d.ts +32 -0
- package/dist/types/src/debug/debug-model.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.test.d.ts +2 -0
- package/dist/types/src/debug/debug-model.test.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.d.ts +48 -0
- package/dist/types/src/debug/frame-meter.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.test.d.ts +2 -0
- package/dist/types/src/debug/frame-meter.test.d.ts.map +1 -0
- package/dist/types/src/debug/index.d.ts +6 -0
- package/dist/types/src/debug/index.d.ts.map +1 -0
- package/dist/types/src/debug/use-debug.d.ts +19 -0
- package/dist/types/src/debug/use-debug.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.d.ts +89 -0
- package/dist/types/src/hooks/follow.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.stories.d.ts +25 -0
- package/dist/types/src/hooks/follow.stories.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.test.d.ts +2 -0
- package/dist/types/src/hooks/follow.test.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +6 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useDecorations.d.ts +31 -0
- package/dist/types/src/hooks/useDecorations.d.ts.map +1 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts +44 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts.map +1 -0
- package/dist/types/src/hooks/useFollow.d.ts +46 -0
- package/dist/types/src/hooks/useFollow.d.ts.map +1 -0
- package/dist/types/src/hooks/useItemSelection.d.ts +34 -0
- package/dist/types/src/hooks/useItemSelection.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.d.ts +67 -0
- package/dist/types/src/model/feed-model.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.test.d.ts +2 -0
- package/dist/types/src/model/feed-model.test.d.ts.map +1 -0
- package/dist/types/src/model/feed.d.ts +66 -0
- package/dist/types/src/model/feed.d.ts.map +1 -0
- package/dist/types/src/model/feed.test.d.ts +2 -0
- package/dist/types/src/model/feed.test.d.ts.map +1 -0
- package/dist/types/src/model/index.d.ts +5 -0
- package/dist/types/src/model/index.d.ts.map +1 -0
- package/dist/types/src/model/list-model.d.ts +61 -0
- package/dist/types/src/model/list-model.d.ts.map +1 -0
- package/dist/types/src/model/list-model.test.d.ts +2 -0
- package/dist/types/src/model/list-model.test.d.ts.map +1 -0
- package/dist/types/src/model/useListModel.d.ts +22 -0
- package/dist/types/src/model/useListModel.d.ts.map +1 -0
- package/dist/types/src/stories/assistant.stories.d.ts +38 -0
- package/dist/types/src/stories/assistant.stories.d.ts.map +1 -0
- package/dist/types/src/stories/baseline.stories.d.ts +32 -0
- package/dist/types/src/stories/baseline.stories.d.ts.map +1 -0
- package/dist/types/src/stories/bridge.stories.d.ts +72 -0
- package/dist/types/src/stories/bridge.stories.d.ts.map +1 -0
- package/dist/types/src/stories/construction.stories.d.ts +17 -0
- package/dist/types/src/stories/construction.stories.d.ts.map +1 -0
- package/dist/types/src/stories/fill.stories.d.ts +51 -0
- package/dist/types/src/stories/fill.stories.d.ts.map +1 -0
- package/dist/types/src/stories/horizontal.stories.d.ts +17 -0
- package/dist/types/src/stories/horizontal.stories.d.ts.map +1 -0
- package/dist/types/src/stories/mount.stories.d.ts +19 -0
- package/dist/types/src/stories/mount.stories.d.ts.map +1 -0
- package/dist/types/src/stories/navigation.stories.d.ts +33 -0
- package/dist/types/src/stories/navigation.stories.d.ts.map +1 -0
- package/dist/types/src/stories/streaming.stories.d.ts +17 -0
- package/dist/types/src/stories/streaming.stories.d.ts.map +1 -0
- package/dist/types/src/stories/tail.stories.d.ts +47 -0
- package/dist/types/src/stories/tail.stories.d.ts.map +1 -0
- package/dist/types/src/stories/widget.stories.d.ts +16 -0
- package/dist/types/src/stories/widget.stories.d.ts.map +1 -0
- package/dist/types/src/testing/FeedStory.d.ts +30 -0
- package/dist/types/src/testing/FeedStory.d.ts.map +1 -0
- package/dist/types/src/testing/MessageWindow.d.ts +48 -0
- package/dist/types/src/testing/MessageWindow.d.ts.map +1 -0
- package/dist/types/src/testing/controls.d.ts +46 -0
- package/dist/types/src/testing/controls.d.ts.map +1 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts +18 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts.map +1 -0
- package/dist/types/src/testing/debug/index.d.ts +4 -0
- package/dist/types/src/testing/debug/index.d.ts.map +1 -0
- package/dist/types/src/testing/debug/sweep.d.ts +18 -0
- package/dist/types/src/testing/debug/sweep.d.ts.map +1 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts +33 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts.map +1 -0
- package/dist/types/src/testing/generator.d.ts +16 -0
- package/dist/types/src/testing/generator.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +10 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/scenarios.d.ts +58 -0
- package/dist/types/src/testing/scenarios.d.ts.map +1 -0
- package/dist/types/src/testing/stream.d.ts +22 -0
- package/dist/types/src/testing/stream.d.ts.map +1 -0
- package/dist/types/src/testing/turn.d.ts +21 -0
- package/dist/types/src/testing/turn.d.ts.map +1 -0
- package/dist/types/src/testing/widgets.d.ts +3 -0
- package/dist/types/src/testing/widgets.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.d.ts +137 -0
- package/dist/types/src/virtualizer/Window.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts +112 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts.map +1 -0
- package/dist/types/src/virtualizer/index.d.ts +3 -0
- package/dist/types/src/virtualizer/index.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.d.ts +188 -0
- package/dist/types/src/virtualizer/placement.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.test.d.ts +2 -0
- package/dist/types/src/virtualizer/placement.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +72 -0
- package/src/components/Block/HtmlBlock.tsx +26 -0
- package/src/components/Block/MarkdownBlock.tsx +157 -0
- package/src/components/Block/block.stories.tsx +140 -0
- package/src/components/Block/construction.ts +77 -0
- package/src/components/Block/extensions.ts +95 -0
- package/src/components/Block/highlight.ts +53 -0
- package/src/components/Block/index.ts +10 -0
- package/src/components/Block/selection-group.ts +53 -0
- package/src/components/Block/widget-state.tsx +75 -0
- package/src/components/MessageList/MessageList.stories.tsx +111 -0
- package/src/components/MessageList/MessageList.tsx +647 -0
- package/src/components/MessageList/index.ts +6 -0
- package/src/components/MessageList/position-log.ts +167 -0
- package/src/components/Minimap/Minimap.stories.tsx +129 -0
- package/src/components/Minimap/Minimap.tsx +84 -0
- package/src/components/Minimap/index.ts +5 -0
- package/src/components/Outline/Outline.stories.tsx +196 -0
- package/src/components/Outline/Outline.tsx +383 -0
- package/src/components/Outline/index.ts +5 -0
- package/src/components/index.ts +8 -0
- package/src/debug/Debug.tsx +90 -0
- package/src/debug/Stats.tsx +57 -0
- package/src/debug/debug-model.test.ts +36 -0
- package/src/debug/debug-model.ts +66 -0
- package/src/debug/frame-meter.test.ts +49 -0
- package/src/debug/frame-meter.ts +188 -0
- package/src/debug/index.ts +9 -0
- package/src/debug/use-debug.tsx +41 -0
- package/src/hooks/follow.stories.tsx +200 -0
- package/src/hooks/follow.test.ts +105 -0
- package/src/hooks/follow.ts +241 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useDecorations.tsx +54 -0
- package/src/hooks/useFeedNavigation.ts +90 -0
- package/src/hooks/useFollow.ts +258 -0
- package/src/hooks/useItemSelection.tsx +53 -0
- package/src/index.ts +7 -0
- package/src/model/feed-model.test.ts +84 -0
- package/src/model/feed-model.ts +214 -0
- package/src/model/feed.test.ts +65 -0
- package/src/model/feed.ts +157 -0
- package/src/model/index.ts +8 -0
- package/src/model/list-model.test.ts +71 -0
- package/src/model/list-model.ts +152 -0
- package/src/model/useListModel.ts +53 -0
- package/src/stories/assistant.stories.tsx +425 -0
- package/src/stories/baseline.stories.tsx +78 -0
- package/src/stories/bridge.stories.tsx +376 -0
- package/src/stories/construction.stories.tsx +147 -0
- package/src/stories/fill.stories.tsx +271 -0
- package/src/stories/horizontal.stories.tsx +129 -0
- package/src/stories/mount.stories.tsx +233 -0
- package/src/stories/navigation.stories.tsx +145 -0
- package/src/stories/streaming.stories.tsx +132 -0
- package/src/stories/tail.stories.tsx +149 -0
- package/src/stories/widget.stories.tsx +107 -0
- package/src/testing/FeedStory.tsx +452 -0
- package/src/testing/MessageWindow.tsx +139 -0
- package/src/testing/controls.tsx +96 -0
- package/src/testing/debug/FeedStats.tsx +182 -0
- package/src/testing/debug/index.ts +16 -0
- package/src/testing/debug/sweep.ts +85 -0
- package/src/testing/debug/use-feed-debug.ts +91 -0
- package/src/testing/generator.ts +90 -0
- package/src/testing/index.ts +13 -0
- package/src/testing/scenarios.tsx +552 -0
- package/src/testing/stream.ts +70 -0
- package/src/testing/turn.ts +91 -0
- package/src/testing/widgets.tsx +183 -0
- package/src/virtualizer/Window.stories.tsx +683 -0
- package/src/virtualizer/Window.tsx +435 -0
- package/src/virtualizer/index.ts +6 -0
- package/src/virtualizer/placement.test.ts +312 -0
- package/src/virtualizer/placement.ts +403 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { type MouseEvent, type PropsWithChildren, createContext, useCallback, useContext } from 'react';
|
|
6
|
+
|
|
7
|
+
/** What an item can do about its own selection — the item-side face of the host's selection model. */
|
|
8
|
+
export type ItemSelection = {
|
|
9
|
+
selected: boolean;
|
|
10
|
+
/** Additive when the gesture says so (meta/ctrl); the mode is the provider's business. */
|
|
11
|
+
toggle: (additive?: boolean) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ItemSelectionValue = {
|
|
15
|
+
selectedIds?: ReadonlySet<string>;
|
|
16
|
+
onSelect?: (id: string, additive: boolean) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ItemSelectionContext = createContext<ItemSelectionValue>({});
|
|
20
|
+
|
|
21
|
+
export type ItemSelectionProviderProps = PropsWithChildren<ItemSelectionValue>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The item-side hook of the decoration pattern applied to selection (SPEC: useItemSelection): an
|
|
25
|
+
* item asks for its own `{ selected, toggle }` by id instead of the list threading `selectedIds`
|
|
26
|
+
* through props. Selection is model-space ids, so virtualization is untouched: an unmounted item is
|
|
27
|
+
* still selected, and mounts knowing it.
|
|
28
|
+
*
|
|
29
|
+
* The provider is deliberately the thinnest thing that works — a set and a callback. Mode policies
|
|
30
|
+
* (single | multi | range) belong to `react-ui-list`'s `useListSelection`, which a host composes
|
|
31
|
+
* *above* this provider; teaching this package about modes would be rebuilding that hook.
|
|
32
|
+
*/
|
|
33
|
+
export const ItemSelectionProvider = ({ selectedIds, onSelect, children }: ItemSelectionProviderProps) => (
|
|
34
|
+
<ItemSelectionContext.Provider value={{ selectedIds, onSelect }}>{children}</ItemSelectionContext.Provider>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The whole selection value, for a component that renders many items in one pass.
|
|
39
|
+
*
|
|
40
|
+
* A row loop cannot call `useItemSelection` per row — hooks do not run in loops — so the viewport
|
|
41
|
+
* reads the value once and derives per-row state; item-shaped components use the per-id hook.
|
|
42
|
+
*/
|
|
43
|
+
export const useItemSelectionValue = (): ItemSelectionValue => useContext(ItemSelectionContext);
|
|
44
|
+
|
|
45
|
+
export const useItemSelection = (id: string): ItemSelection => {
|
|
46
|
+
const { selectedIds, onSelect } = useContext(ItemSelectionContext);
|
|
47
|
+
const toggle = useCallback((additive = true) => onSelect?.(id, additive), [id, onSelect]);
|
|
48
|
+
return { selected: selectedIds?.has(id) ?? false, toggle };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Reads the additive intent off a gesture, so callers do not each invent the modifier rule. */
|
|
52
|
+
export const isAdditive = (event: MouseEvent | globalThis.MouseEvent): boolean =>
|
|
53
|
+
event.metaKey || event.ctrlKey || event.shiftKey;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { Message } from '@dxos/types';
|
|
8
|
+
|
|
9
|
+
import { defaultRenderer, messageText, searchFeed, sliceFeed } from './feed-model';
|
|
10
|
+
|
|
11
|
+
const makeMessage = (text: string, mimeType?: string) =>
|
|
12
|
+
Message.make({ sender: { role: 'user' }, blocks: [{ _tag: 'text', text, ...(mimeType ? { mimeType } : {}) }] });
|
|
13
|
+
|
|
14
|
+
describe('feed model', () => {
|
|
15
|
+
test('renders markdown by default and html when the block declares it', () => {
|
|
16
|
+
expect(defaultRenderer(makeMessage('# Hello'))).toEqual({ kind: 'markdown', text: '# Hello' });
|
|
17
|
+
expect(defaultRenderer(makeMessage('<p>Hello</p>', 'text/html'))).toEqual({ kind: 'html', html: '<p>Hello</p>' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('html messages are searchable as text', () => {
|
|
21
|
+
expect(messageText(makeMessage('<p>Hello <b>world</b></p>', 'text/html'), defaultRenderer)).toEqual('Hello world');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('search', () => {
|
|
25
|
+
test('finds every occurrence across messages, with the index for scroll-to', () => {
|
|
26
|
+
const messages = [makeMessage('alpha beta'), makeMessage('gamma'), makeMessage('beta beta')];
|
|
27
|
+
const hits = searchFeed(messages, defaultRenderer, 'beta');
|
|
28
|
+
expect(hits.map(({ index, offset }) => [index, offset])).toEqual([
|
|
29
|
+
[0, 6],
|
|
30
|
+
[2, 0],
|
|
31
|
+
[2, 5],
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('is case-insensitive and empty for an empty query', () => {
|
|
36
|
+
const messages = [makeMessage('Alpha')];
|
|
37
|
+
expect(searchFeed(messages, defaultRenderer, 'alpha')).toHaveLength(1);
|
|
38
|
+
expect(searchFeed(messages, defaultRenderer, '')).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('reaches messages that no viewport has mounted', () => {
|
|
42
|
+
// The engine only mounts the visible window; search runs over the model, so a hit 500 messages
|
|
43
|
+
// down is found without ever rendering it.
|
|
44
|
+
const messages = [...Array(500)].map((_, index) => makeMessage(`message ${index}`));
|
|
45
|
+
expect(searchFeed(messages, defaultRenderer, 'message 499')).toMatchObject([{ index: 499, offset: 0 }]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('slice', () => {
|
|
50
|
+
const messages = [makeMessage('alpha'), makeMessage('beta'), makeMessage('gamma')];
|
|
51
|
+
|
|
52
|
+
test('spans message boundaries', () => {
|
|
53
|
+
const text = sliceFeed(messages, defaultRenderer, {
|
|
54
|
+
from: { messageId: messages[0].id, offset: 2 },
|
|
55
|
+
to: { messageId: messages[2].id, offset: 3 },
|
|
56
|
+
});
|
|
57
|
+
expect(text).toEqual('pha\n\nbeta\n\ngam');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('is symmetric for a backwards drag', () => {
|
|
61
|
+
const range = {
|
|
62
|
+
from: { messageId: messages[2].id, offset: 3 },
|
|
63
|
+
to: { messageId: messages[0].id, offset: 2 },
|
|
64
|
+
};
|
|
65
|
+
expect(sliceFeed(messages, defaultRenderer, range)).toEqual('pha\n\nbeta\n\ngam');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('handles a range within one message', () => {
|
|
69
|
+
const text = sliceFeed(messages, defaultRenderer, {
|
|
70
|
+
from: { messageId: messages[1].id, offset: 1 },
|
|
71
|
+
to: { messageId: messages[1].id, offset: 3 },
|
|
72
|
+
});
|
|
73
|
+
expect(text).toEqual('et');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('returns nothing for a stale anchor', () => {
|
|
77
|
+
const text = sliceFeed(messages, defaultRenderer, {
|
|
78
|
+
from: { messageId: 'gone', offset: 0 },
|
|
79
|
+
to: { messageId: messages[1].id, offset: 1 },
|
|
80
|
+
});
|
|
81
|
+
expect(text).toEqual('');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type ContentBlock, type Message } from '@dxos/types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What an item renders. The engine is renderer-agnostic: a message may resolve to a markdown
|
|
9
|
+
* document, to raw HTML (email bodies, which are not markdown and cannot be a CodeMirror document),
|
|
10
|
+
* or to a caller-supplied component.
|
|
11
|
+
*/
|
|
12
|
+
export type ItemContent =
|
|
13
|
+
| { kind: 'markdown'; text: string }
|
|
14
|
+
| { kind: 'html'; html: string }
|
|
15
|
+
| { kind: 'custom'; key: string; data?: unknown };
|
|
16
|
+
|
|
17
|
+
/** Resolves a message to the content its item renders. One per scenario (chat, email, …). */
|
|
18
|
+
export type MessageRenderer = (message: Message.Message) => ItemContent;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default renderer: an `text/html` block wins (email), otherwise every block is flattened to
|
|
22
|
+
* markdown. Block kinds the engine does not know become fenced JSON so nothing renders as blank.
|
|
23
|
+
*/
|
|
24
|
+
export const defaultRenderer: MessageRenderer = (message) => createContent(message, false);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Assistant chat: as {@link defaultRenderer}, plus the reader's own turns marked with `<prompt>`.
|
|
28
|
+
*
|
|
29
|
+
* Separate because the tag is only meaningful where a widget registry knows it — a feed rendered
|
|
30
|
+
* without one would show the reader their own angle brackets, which is what a human chat, a
|
|
31
|
+
* transcript and a comment list all are.
|
|
32
|
+
*/
|
|
33
|
+
export const chatRenderer: MessageRenderer = (message) => createContent(message, true);
|
|
34
|
+
|
|
35
|
+
const createContent = (message: Message.Message, prompts: boolean): ItemContent => {
|
|
36
|
+
const textBlocks = message.blocks.filter((block): block is ContentBlock.Text => block._tag === 'text');
|
|
37
|
+
const html = textBlocks.find((block) => block.mimeType === 'text/html');
|
|
38
|
+
if (html) {
|
|
39
|
+
return { kind: 'html', html: html.text };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
kind: 'markdown',
|
|
44
|
+
text: message.blocks
|
|
45
|
+
.map((block) => blockToMarkdown(message, block, prompts))
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.join('\n\n'),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One block as markdown, with anything that is not prose emitted as an XML tag for a widget to
|
|
53
|
+
* render. A message is the concatenation of these, which is what makes an item a document rather
|
|
54
|
+
* than a component tree: the blocks are content, and the chrome around them belongs to the host.
|
|
55
|
+
*/
|
|
56
|
+
const blockToMarkdown = (message: Message.Message, block: ContentBlock.Any, prompts: boolean): string => {
|
|
57
|
+
switch (block._tag) {
|
|
58
|
+
case 'text':
|
|
59
|
+
// A prompt is marked rather than styled: the reader's own words are content, and the widget
|
|
60
|
+
// that frames them is the host's business.
|
|
61
|
+
return prompts && message.sender.role === 'user' ? tag('prompt', block.text, block) : block.text.trim();
|
|
62
|
+
case 'reasoning':
|
|
63
|
+
return tag('reasoning', block.reasoningText ?? block.redactedText ?? '', block);
|
|
64
|
+
case 'status':
|
|
65
|
+
return tag('status', block.statusText, block);
|
|
66
|
+
case 'summary':
|
|
67
|
+
return tag('summary', block.content, block);
|
|
68
|
+
case 'suggestion':
|
|
69
|
+
return tag('suggestion', block.text, block);
|
|
70
|
+
case 'select':
|
|
71
|
+
return block.options.length
|
|
72
|
+
? `<select>${block.options.map((option) => `<option>${escapeXml(option)}</option>`).join('')}</select>`
|
|
73
|
+
: '';
|
|
74
|
+
case 'toolCall':
|
|
75
|
+
// Name and status ride on the tag rather than in widget state kept beside the document: an
|
|
76
|
+
// item is rebuilt from its message alone, so anything the widget needs has to survive there.
|
|
77
|
+
return `<toolCall id="${escapeXml(block.toolCallId)}" name="${escapeXml(block.name)}"${block.pending ? ' pending="true"' : ''} />`;
|
|
78
|
+
case 'toolResult':
|
|
79
|
+
return tag('toolResult', String(block.result ?? block.error ?? ''), block, `for="${escapeXml(block.name)}"`);
|
|
80
|
+
default:
|
|
81
|
+
// Nothing renders blank: an unknown block is shown as what it is.
|
|
82
|
+
return tag('json', JSON.stringify(block), block);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* An XML tag holding block content.
|
|
88
|
+
*
|
|
89
|
+
* A pending block is emitted **unclosed**, which is the streaming contract: the item reconciles by
|
|
90
|
+
* appending, so a closing tag written before the content is complete would have to be rewritten on
|
|
91
|
+
* every chunk — and rewriting the document is what discards decorations and scroll position.
|
|
92
|
+
*
|
|
93
|
+
* Paragraph breaks inside the content are collapsed, since a blank line would end the enclosing
|
|
94
|
+
* markdown block and leave the rest of the tag parsing as prose.
|
|
95
|
+
*/
|
|
96
|
+
const tag = (name: string, content: string, block: ContentBlock.Any, attributes?: string): string => {
|
|
97
|
+
const text = escapeXml(content.replace(/\n\n+/g, ' ').trim());
|
|
98
|
+
if (!text.length && !block.pending) {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const open = `<${name}${attributes ? ` ${attributes}` : ''}>`;
|
|
103
|
+
return block.pending ? `${open}${text}` : `${open}${text}</${name}>`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const escapeXml = (raw: string): string => raw.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
107
|
+
|
|
108
|
+
/** Plain text of a message, used by search and copy so both read the same projection. */
|
|
109
|
+
export const messageText = (message: Message.Message, renderer: MessageRenderer): string => {
|
|
110
|
+
const content = renderer(message);
|
|
111
|
+
switch (content.kind) {
|
|
112
|
+
case 'markdown':
|
|
113
|
+
return content.text;
|
|
114
|
+
case 'html':
|
|
115
|
+
return stripHtml(content.html);
|
|
116
|
+
case 'custom':
|
|
117
|
+
return '';
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Crude tag strip — the engine only needs searchable/copyable text, not a parse tree. */
|
|
122
|
+
const stripHtml = (html: string): string =>
|
|
123
|
+
html
|
|
124
|
+
.replace(/<[^>]*>/g, ' ')
|
|
125
|
+
.replace(/\s+/g, ' ')
|
|
126
|
+
.trim();
|
|
127
|
+
|
|
128
|
+
//
|
|
129
|
+
// Search
|
|
130
|
+
//
|
|
131
|
+
|
|
132
|
+
export type SearchHit = {
|
|
133
|
+
messageId: string;
|
|
134
|
+
/** Index into the message list, so the virtualizer can scroll to it without a second lookup. */
|
|
135
|
+
index: number;
|
|
136
|
+
/** Offset into the message's rendered text. */
|
|
137
|
+
offset: number;
|
|
138
|
+
length: number;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Search the model rather than the DOM.
|
|
143
|
+
*
|
|
144
|
+
* Virtualization means most messages are not mounted, and a `viewType` filter can hide blocks that
|
|
145
|
+
* are still in the model — neither is reachable by a per-view CodeMirror search, so thread-wide find
|
|
146
|
+
* has to run over the projection.
|
|
147
|
+
*/
|
|
148
|
+
export const searchFeed = (
|
|
149
|
+
messages: readonly Message.Message[],
|
|
150
|
+
renderer: MessageRenderer,
|
|
151
|
+
query: string,
|
|
152
|
+
): SearchHit[] => {
|
|
153
|
+
const hits: SearchHit[] = [];
|
|
154
|
+
if (!query.length) {
|
|
155
|
+
return hits;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const needle = query.toLowerCase();
|
|
159
|
+
messages.forEach((message, index) => {
|
|
160
|
+
const text = messageText(message, renderer).toLowerCase();
|
|
161
|
+
let offset = text.indexOf(needle);
|
|
162
|
+
while (offset !== -1) {
|
|
163
|
+
hits.push({ messageId: message.id, index, offset, length: query.length });
|
|
164
|
+
offset = text.indexOf(needle, offset + needle.length);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return hits;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
//
|
|
172
|
+
// Selection
|
|
173
|
+
//
|
|
174
|
+
|
|
175
|
+
/** A point in the feed: a message plus an offset into its rendered text. */
|
|
176
|
+
export type FeedAnchor = { messageId: string; offset: number };
|
|
177
|
+
|
|
178
|
+
export type FeedRange = { from: FeedAnchor; to: FeedAnchor };
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Text between two anchors, spanning message boundaries.
|
|
182
|
+
*
|
|
183
|
+
* Native selection cannot span separate editor instances, and unmounted messages are not in the DOM
|
|
184
|
+
* at all, so cross-message copy is reconstructed from the model.
|
|
185
|
+
*/
|
|
186
|
+
export const sliceFeed = (
|
|
187
|
+
messages: readonly Message.Message[],
|
|
188
|
+
renderer: MessageRenderer,
|
|
189
|
+
range: FeedRange,
|
|
190
|
+
): string => {
|
|
191
|
+
const fromIndex = messages.findIndex((message) => message.id === range.from.messageId);
|
|
192
|
+
const toIndex = messages.findIndex((message) => message.id === range.to.messageId);
|
|
193
|
+
if (fromIndex === -1 || toIndex === -1) {
|
|
194
|
+
return '';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Normalize so a backwards drag (focus before anchor) yields the same text as a forwards one.
|
|
198
|
+
const [start, end] =
|
|
199
|
+
fromIndex < toIndex || (fromIndex === toIndex && range.from.offset <= range.to.offset)
|
|
200
|
+
? [range.from, range.to]
|
|
201
|
+
: [range.to, range.from];
|
|
202
|
+
|
|
203
|
+
const [startIndex, endIndex] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
|
|
204
|
+
|
|
205
|
+
const parts: string[] = [];
|
|
206
|
+
for (let index = startIndex; index <= endIndex; index++) {
|
|
207
|
+
const text = messageText(messages[index], renderer);
|
|
208
|
+
const from = index === startIndex ? start.offset : 0;
|
|
209
|
+
const to = index === endIndex ? end.offset : text.length;
|
|
210
|
+
parts.push(text.slice(from, to));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return parts.join('\n\n');
|
|
214
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { Message } from '@dxos/types';
|
|
8
|
+
|
|
9
|
+
import { FeedModel, fromMessages } from './feed';
|
|
10
|
+
|
|
11
|
+
const message = (role: 'user' | 'assistant', text: string) =>
|
|
12
|
+
Message.make({ sender: { role, name: role }, blocks: [{ _tag: 'text', text }] });
|
|
13
|
+
|
|
14
|
+
describe('FeedModel', () => {
|
|
15
|
+
test('stops follow the policy, and the policy is swappable', () => {
|
|
16
|
+
const model = fromMessages([
|
|
17
|
+
message('user', 'question one'),
|
|
18
|
+
message('assistant', 'answer one'),
|
|
19
|
+
message('user', 'question two'),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
expect(model.stops().map(({ index }) => index)).to.deep.eq([0, 1, 2]);
|
|
23
|
+
|
|
24
|
+
model.setStops('prompt');
|
|
25
|
+
expect(model.stops().map(({ index }) => index)).to.deep.eq([0, 2]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('the model owns iteration: an edge asks the source, and the page arrives as a prepend', async () => {
|
|
29
|
+
const history = [message('user', 'older')];
|
|
30
|
+
const model = new FeedModel({
|
|
31
|
+
messages: [message('user', 'newer')],
|
|
32
|
+
loadBefore: async () => history.splice(0),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const changes: unknown[] = [];
|
|
36
|
+
model.subscribe((change) => changes.push(change));
|
|
37
|
+
|
|
38
|
+
expect(await model.more('start')).to.eq(true);
|
|
39
|
+
expect(changes).to.deep.eq([{ prepended: 1 }]);
|
|
40
|
+
// The source is drained; the edge stops asking.
|
|
41
|
+
expect(await model.more('start')).to.eq(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('streaming is an update to one identity, not a change to the list', () => {
|
|
45
|
+
const answer = message('assistant', 'streaming');
|
|
46
|
+
const model = fromMessages([message('user', 'prompt'), answer]);
|
|
47
|
+
const changes: unknown[] = [];
|
|
48
|
+
model.subscribe((change) => changes.push(change));
|
|
49
|
+
|
|
50
|
+
model.setStreaming(answer.id);
|
|
51
|
+
model.stream(answer.id);
|
|
52
|
+
model.setStreaming(undefined);
|
|
53
|
+
|
|
54
|
+
expect(model.count).to.eq(2);
|
|
55
|
+
expect(changes).to.deep.eq([{ updated: [answer.id] }, { updated: [answer.id] }, { updated: [answer.id] }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('search runs over the model, since the DOM cannot see unmounted rows', () => {
|
|
59
|
+
const model = fromMessages([message('user', 'find the needle'), message('assistant', 'nothing here')]);
|
|
60
|
+
|
|
61
|
+
const hits = model.search('needle');
|
|
62
|
+
expect(hits.length).to.eq(1);
|
|
63
|
+
expect(hits[0].index).to.eq(0);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Message } from '@dxos/types';
|
|
6
|
+
|
|
7
|
+
import { type MessageRenderer, type SearchHit, defaultRenderer, messageText, searchFeed } from './feed-model';
|
|
8
|
+
import { ListModel } from './list-model';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One position navigation can land on (SPEC: Stop).
|
|
12
|
+
*
|
|
13
|
+
* Produced by a pluggable policy over the model and consumed identically by every driver —
|
|
14
|
+
* toolbar, arrow keys, outliner, minimap — which is what keeps the rails and the keymap from
|
|
15
|
+
* growing parallel wiring.
|
|
16
|
+
*/
|
|
17
|
+
export type Stop = {
|
|
18
|
+
index: number;
|
|
19
|
+
id: string;
|
|
20
|
+
label?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The built-in policies; a host may hand a function instead. */
|
|
24
|
+
export type StopsPolicy = 'message' | 'prompt' | ((message: Message.Message, index: number) => boolean);
|
|
25
|
+
|
|
26
|
+
export type FeedModelOptions = {
|
|
27
|
+
messages?: readonly Message.Message[];
|
|
28
|
+
stops?: StopsPolicy;
|
|
29
|
+
/** Paged history: consumed when the window reaches the start edge. Paging is the model's business. */
|
|
30
|
+
loadBefore?: () => Promise<readonly Message.Message[]>;
|
|
31
|
+
/** The forward twin, for feeds that are windows into something longer. */
|
|
32
|
+
loadAfter?: () => Promise<readonly Message.Message[]>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The native case, not an add-on (SPEC: FeedModel): a ListModel of messages carrying the feed's
|
|
37
|
+
* own semantics — the stops policy, the streaming tail, and iteration. The list consumes this and
|
|
38
|
+
* nothing else; `hits`, `isAnchor` and `streamingId` all left the props API and live here or in
|
|
39
|
+
* the decoration provider.
|
|
40
|
+
*
|
|
41
|
+
* ECHO/queue bindings adapt to this outside the package; `fromMessages` adapts the plain case.
|
|
42
|
+
*/
|
|
43
|
+
export class FeedModel extends ListModel<Message.Message> {
|
|
44
|
+
#stops: StopsPolicy;
|
|
45
|
+
#streamingId: string | undefined;
|
|
46
|
+
#loading = false;
|
|
47
|
+
readonly #loadBefore?: () => Promise<readonly Message.Message[]>;
|
|
48
|
+
readonly #loadAfter?: () => Promise<readonly Message.Message[]>;
|
|
49
|
+
|
|
50
|
+
constructor({ messages = [], stops = 'message', loadBefore, loadAfter }: FeedModelOptions = {}) {
|
|
51
|
+
super({ items: messages, getId: (message) => message.id });
|
|
52
|
+
this.#stops = stops;
|
|
53
|
+
this.#loadBefore = loadBefore;
|
|
54
|
+
this.#loadAfter = loadAfter;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get messages(): readonly Message.Message[] {
|
|
58
|
+
return this.items;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The tail message reconciling by delta; its item must not remount per chunk. */
|
|
62
|
+
get streamingId(): string | undefined {
|
|
63
|
+
return this.#streamingId;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setStreaming(id: string | undefined): void {
|
|
67
|
+
const previous = this.#streamingId;
|
|
68
|
+
this.#streamingId = id;
|
|
69
|
+
// The item that stopped (or started) streaming re-renders; the rest of the feed does not care.
|
|
70
|
+
for (const changed of [previous, id]) {
|
|
71
|
+
if (changed) {
|
|
72
|
+
this.update(changed);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A chunk arrived for the streaming tail: same identity, longer content. */
|
|
78
|
+
stream(id: string): void {
|
|
79
|
+
this.update(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setStops(stops: StopsPolicy): void {
|
|
83
|
+
this.#stops = stops;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The positions navigation can land on, under the current policy.
|
|
88
|
+
*
|
|
89
|
+
* Recomputed on demand rather than cached: a policy is a predicate over the model, and the model
|
|
90
|
+
* knows when it changed — callers that want reactivity derive from `rowsAtom`.
|
|
91
|
+
*/
|
|
92
|
+
stops(): Stop[] {
|
|
93
|
+
const predicate =
|
|
94
|
+
this.#stops === 'message'
|
|
95
|
+
? () => true
|
|
96
|
+
: this.#stops === 'prompt'
|
|
97
|
+
? (message: Message.Message) => message.sender.role === 'user'
|
|
98
|
+
: this.#stops;
|
|
99
|
+
|
|
100
|
+
const stops: Stop[] = [];
|
|
101
|
+
this.messages.forEach((message, index) => {
|
|
102
|
+
if (predicate(message, index)) {
|
|
103
|
+
stops.push({ index, id: message.id });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return stops;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The window reached the start: ask the source for history.
|
|
112
|
+
*
|
|
113
|
+
* The model owns iteration (SPEC F-7.2) — the pages arrive as a prepend, which the virtualizer
|
|
114
|
+
* is told about, so nothing on screen moves (F-1.1). Serialized, because edges are reached once
|
|
115
|
+
* per frame while the reader sits on them.
|
|
116
|
+
*/
|
|
117
|
+
async more(edge: 'start' | 'end'): Promise<boolean> {
|
|
118
|
+
const load = edge === 'start' ? this.#loadBefore : this.#loadAfter;
|
|
119
|
+
if (!load || this.#loading) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.#loading = true;
|
|
124
|
+
try {
|
|
125
|
+
const page = await load();
|
|
126
|
+
if (!page.length) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (edge === 'start') {
|
|
131
|
+
this.prepend(page);
|
|
132
|
+
} else {
|
|
133
|
+
this.append(page);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return true;
|
|
137
|
+
} finally {
|
|
138
|
+
this.#loading = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Model-level search, since virtualization hides most items from any DOM search. */
|
|
143
|
+
search(query: string, renderer: MessageRenderer = defaultRenderer): SearchHit[] {
|
|
144
|
+
return searchFeed(this.messages, renderer, query);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The rendered text of one message, for previews and rails. */
|
|
148
|
+
textOf(id: string, renderer: MessageRenderer = defaultRenderer): string | undefined {
|
|
149
|
+
const index = this.indexOf(id);
|
|
150
|
+
const message = this.at(index);
|
|
151
|
+
return message && messageText(message, renderer);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The plain case: a feed over an array the host already has. */
|
|
156
|
+
export const fromMessages = (messages: readonly Message.Message[], options: Omit<FeedModelOptions, 'messages'> = {}) =>
|
|
157
|
+
new FeedModel({ messages, ...options });
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { ListModel } from './list-model';
|
|
8
|
+
|
|
9
|
+
const create = (count = 5) =>
|
|
10
|
+
new ListModel<{ id: string }>({
|
|
11
|
+
items: Array.from({ length: count }, (_, index) => ({ id: `row-${index}` })),
|
|
12
|
+
getId: (item) => item.id,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('ListModel', () => {
|
|
16
|
+
test('a prepend is told, never inferred', () => {
|
|
17
|
+
const model = create();
|
|
18
|
+
const changes: unknown[] = [];
|
|
19
|
+
model.subscribe((change) => changes.push(change));
|
|
20
|
+
|
|
21
|
+
model.prepend([{ id: 'earlier-0' }, { id: 'earlier-1' }]);
|
|
22
|
+
|
|
23
|
+
expect(changes).to.deep.eq([{ prepended: 2 }]);
|
|
24
|
+
expect(model.getId(0)).to.eq('earlier-0');
|
|
25
|
+
expect(model.getId(2)).to.eq('row-0');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('replace recovers the prepend for a caller that does not know', () => {
|
|
29
|
+
// The one place the identity scan survives: a host handing over a whole new array. The model
|
|
30
|
+
// works out that everything before the old first row was prepended, so the virtualizer is
|
|
31
|
+
// still told rather than left to infer.
|
|
32
|
+
const model = create(3);
|
|
33
|
+
const changes: unknown[] = [];
|
|
34
|
+
model.subscribe((change) => changes.push(change));
|
|
35
|
+
|
|
36
|
+
model.replace([{ id: 'earlier-0' }, { id: 'row-0' }, { id: 'row-1' }, { id: 'row-2' }]);
|
|
37
|
+
|
|
38
|
+
expect(changes).to.deep.eq([{ prepended: 1, appended: undefined }]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('replace reads growth at the end as an append', () => {
|
|
42
|
+
const model = create(3);
|
|
43
|
+
const changes: unknown[] = [];
|
|
44
|
+
model.subscribe((change) => changes.push(change));
|
|
45
|
+
|
|
46
|
+
model.replace([{ id: 'row-0' }, { id: 'row-1' }, { id: 'row-2' }, { id: 'later-0' }]);
|
|
47
|
+
|
|
48
|
+
expect(changes).to.deep.eq([{ prepended: undefined, appended: 1 }]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('patch replaces a frozen item under its own identity', () => {
|
|
52
|
+
const model = create(3);
|
|
53
|
+
const changes: unknown[] = [];
|
|
54
|
+
model.subscribe((change) => changes.push(change));
|
|
55
|
+
|
|
56
|
+
model.patch('row-1', { id: 'row-1' });
|
|
57
|
+
// A patch that would change identity is refused: it would strand measurements and selection.
|
|
58
|
+
model.patch('row-2', { id: 'imposter' });
|
|
59
|
+
|
|
60
|
+
expect(changes).to.deep.eq([{ updated: ['row-1'] }]);
|
|
61
|
+
expect(model.getId(2)).to.eq('row-2');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('the atom face tracks the same rows', () => {
|
|
65
|
+
const model = create(2);
|
|
66
|
+
model.append([{ id: 'later-0' }]);
|
|
67
|
+
|
|
68
|
+
expect(model.count).to.eq(3);
|
|
69
|
+
expect(model.items.map((item) => item.id)).to.deep.eq(['row-0', 'row-1', 'later-0']);
|
|
70
|
+
});
|
|
71
|
+
});
|