switch over to deep diffing for mutations
This commit is contained in:
parent
2e3a3e90de
commit
6064fd3b45
7 changed files with 228 additions and 88 deletions
|
@ -2,22 +2,27 @@ import { Accessor, Component, createContext, createSignal, For, JSX, Show, useCo
|
|||
import { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai";
|
||||
import { SelectionProvider, selectable } from "~/features/selectable";
|
||||
import css from "./filetree.module.css";
|
||||
import { debounce } from "~/utilities";
|
||||
|
||||
selectable;
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
id: string;
|
||||
kind: 'file';
|
||||
meta: File;
|
||||
}
|
||||
|
||||
export interface FolderEntry {
|
||||
name: string;
|
||||
id: string;
|
||||
kind: 'folder';
|
||||
entries: Entry[];
|
||||
}
|
||||
|
||||
export type Entry = FileEntry | FolderEntry;
|
||||
|
||||
export const emptyFolder: FolderEntry = { name: '', kind: 'folder', entries: [] } as const;
|
||||
export const emptyFolder: FolderEntry = { name: '', id: '', kind: 'folder', entries: [] } as const;
|
||||
|
||||
export async function* walk(directory: FileSystemDirectoryHandle, filters: RegExp[] = [], depth = 0): AsyncGenerator<Entry, void, never> {
|
||||
if (depth === 10) {
|
||||
|
@ -25,16 +30,17 @@ export async function* walk(directory: FileSystemDirectoryHandle, filters: RegEx
|
|||
}
|
||||
|
||||
for await (const handle of directory.values()) {
|
||||
|
||||
if (filters.some(f => f.test(handle.name))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = await handle.getUniqueId();
|
||||
|
||||
if (handle.kind === 'file') {
|
||||
yield { name: handle.name, kind: 'file', meta: await handle.getFile() };
|
||||
yield { name: handle.name, id, kind: 'file', meta: await handle.getFile() };
|
||||
}
|
||||
else {
|
||||
yield { name: handle.name, kind: 'folder', entries: await Array.fromAsync(walk(handle, filters, depth + 1)) };
|
||||
yield { name: handle.name, id, kind: 'folder', entries: await Array.fromAsync(walk(handle, filters, depth + 1)) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -48,15 +54,8 @@ const TreeContext = createContext<TreeContextType>();
|
|||
export const Tree: Component<{ entries: Entry[], children: (file: Accessor<FileEntry>) => JSX.Element, open?: TreeContextType['open'] }> = (props) => {
|
||||
const [selection, setSelection] = createSignal<object[]>([]);
|
||||
|
||||
// createEffect(() => {
|
||||
// console.log(selection());
|
||||
// });
|
||||
|
||||
const context = {
|
||||
open: props.open ?? (() => { }),
|
||||
// open(file: File) {
|
||||
// console.log(`open ${file.name}`)
|
||||
// },
|
||||
};
|
||||
|
||||
return <SelectionProvider selection={setSelection}>
|
||||
|
@ -76,16 +75,16 @@ const _Tree: Component<{ entries: Entry[], children: (file: Accessor<FileEntry>)
|
|||
}</Show>
|
||||
|
||||
<Show when={entry.kind === 'file' ? entry : undefined}>{
|
||||
file => <span use:selectable={file()} ondblclick={() => context?.open(file().meta)}><AiFillFile /> {props.children(file)}</span>
|
||||
file => <span use:selectable={{ value: file() }} ondblclick={() => context?.open(file().meta)}><AiFillFile /> {props.children(file)}</span>
|
||||
}</Show>
|
||||
</>
|
||||
}</For>
|
||||
}
|
||||
|
||||
const Folder: Component<{ folder: FolderEntry, children: (file: Accessor<FileEntry>) => JSX.Element }> = (props) => {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [open, setOpen] = createSignal(true);
|
||||
|
||||
return <details open={open()} ontoggle={() => setOpen(o => !o)}>
|
||||
return <details open={open()} ontoggle={() => debounce(() => setOpen(o => !o), 1)}>
|
||||
<summary><Show when={open()} fallback={<AiFillFolder />}><AiFillFolderOpen /></Show> {props.folder.name}</summary>
|
||||
<_Tree entries={props.folder.entries} children={props.children} />
|
||||
</details>;
|
||||
|
|
|
@ -1,35 +1,24 @@
|
|||
import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, onMount, ParentComponent, Show, useContext } from "solid-js";
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { SelectionProvider, useSelection, selectable } from "../selectable";
|
||||
import { debounce, deepCopy, deepDiff, Mutation } from "~/utilities";
|
||||
import css from './grid.module.css';
|
||||
|
||||
selectable // prevents removal of import
|
||||
|
||||
const debounce = <T extends (...args: any[]) => void>(callback: T, delay: number): T => {
|
||||
let handle: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return (...args: any[]) => {
|
||||
if (handle) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
|
||||
handle = setTimeout(() => callback(...args), delay);
|
||||
}
|
||||
};
|
||||
|
||||
interface Leaf extends Record<string, string> { }
|
||||
export interface Entry extends Record<string, Entry | Leaf> { }
|
||||
|
||||
type Rows = Record<string, { [lang: string]: { original: string, value: string } }>;
|
||||
type Rows = Map<string, Record<string, string>>;
|
||||
|
||||
export interface GridContextType {
|
||||
readonly rows: Accessor<Rows>;
|
||||
readonly mutations: Accessor<Mutation[]>;
|
||||
readonly selection: Accessor<object[]>;
|
||||
mutate(prop: string, lang: string, value: string): void;
|
||||
}
|
||||
|
||||
export interface GridApi {
|
||||
readonly rows: Accessor<Rows>;
|
||||
readonly mutations: Accessor<Mutation[]>;
|
||||
selectAll(): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
@ -39,19 +28,19 @@ const GridContext = createContext<GridContextType>();
|
|||
const isLeaf = (entry: Entry | Leaf): entry is Leaf => Object.values(entry).some(v => typeof v === 'string');
|
||||
const useGrid = () => useContext(GridContext)!;
|
||||
|
||||
const GridProvider: ParentComponent<{ rows: Map<string, { [lang: string]: { value: string, handle: FileSystemFileHandle } }> }> = (props) => {
|
||||
const GridProvider: ParentComponent<{ rows: Rows }> = (props) => {
|
||||
const [selection, setSelection] = createSignal<object[]>([]);
|
||||
const [state, setState] = createStore<{ rows: Rows, numberOfRows: number }>({
|
||||
const [state, setState] = createStore<{ rows: Record<string, Record<string, string>>, snapshot: Rows, numberOfRows: number }>({
|
||||
rows: {},
|
||||
snapshot: new Map,
|
||||
numberOfRows: 0,
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const rows = props.rows
|
||||
.entries()
|
||||
.map(([prop, entry]) => [prop, Object.fromEntries(Object.entries(entry).map(([lang, { value }]) => [lang, { original: value, value }]))]);
|
||||
const mutations = createMemo(() => deepDiff(state.snapshot, state.rows).toArray());
|
||||
|
||||
setState('rows', Object.fromEntries(rows));
|
||||
createEffect(() => {
|
||||
setState('rows', Object.fromEntries(deepCopy(props.rows).entries()));
|
||||
setState('snapshot', props.rows);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
|
@ -59,13 +48,11 @@ const GridProvider: ParentComponent<{ rows: Map<string, { [lang: string]: { valu
|
|||
});
|
||||
|
||||
const ctx: GridContextType = {
|
||||
rows: createMemo(() => state.rows),
|
||||
mutations,
|
||||
selection,
|
||||
|
||||
mutate(prop: string, lang: string, value: string) {
|
||||
setState('rows', produce(rows => {
|
||||
rows[prop][lang].value = value;
|
||||
}));
|
||||
setState('rows', prop, lang, value);
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -76,32 +63,29 @@ const GridProvider: ParentComponent<{ rows: Map<string, { [lang: string]: { valu
|
|||
</GridContext.Provider>;
|
||||
};
|
||||
|
||||
export const Grid: Component<{ class?: string, columns: string[], rows: Map<string, { [lang: string]: { value: string, handle: FileSystemFileHandle } }>, api?: (api: GridApi) => any }> = (props) => {
|
||||
export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => {
|
||||
const columnCount = createMemo(() => props.columns.length - 1);
|
||||
const root = createMemo<Entry>(() => {
|
||||
return props.rows
|
||||
?.entries()
|
||||
.map(([key, value]) => [key, Object.fromEntries(Object.entries(value).map(([lang, { value }]) => [lang, value]))] as const)
|
||||
.reduce((aggregate, [key, entry]) => {
|
||||
let obj: any = aggregate;
|
||||
const parts = key.split('.');
|
||||
const root = createMemo<Entry>(() => props.rows
|
||||
?.entries()
|
||||
.reduce((aggregate, [key, value]) => {
|
||||
let obj: any = aggregate;
|
||||
const parts = key.split('.');
|
||||
|
||||
for (const [i, part] of parts.entries()) {
|
||||
if (Object.hasOwn(obj, part) === false) {
|
||||
obj[part] = {};
|
||||
}
|
||||
|
||||
if (i === (parts.length - 1)) {
|
||||
obj[part] = entry;
|
||||
}
|
||||
else {
|
||||
obj = obj[part];
|
||||
}
|
||||
for (const [i, part] of parts.entries()) {
|
||||
if (Object.hasOwn(obj, part) === false) {
|
||||
obj[part] = {};
|
||||
}
|
||||
|
||||
return aggregate;
|
||||
}, {});
|
||||
});
|
||||
if (i === (parts.length - 1)) {
|
||||
obj[part] = value;
|
||||
}
|
||||
else {
|
||||
obj = obj[part];
|
||||
}
|
||||
}
|
||||
|
||||
return aggregate;
|
||||
}, {}));
|
||||
|
||||
return <section class={`${css.table} ${props.class}`} style={{ '--columns': columnCount() }}>
|
||||
<GridProvider rows={props.rows}>
|
||||
|
@ -121,7 +105,7 @@ const Api: Component<{ api: undefined | ((api: GridApi) => any) }> = (props) =>
|
|||
const selectionContext = useSelection();
|
||||
|
||||
const api: GridApi = {
|
||||
rows: gridContext.rows,
|
||||
mutations: gridContext.mutations,
|
||||
selectAll() {
|
||||
selectionContext.selectAll();
|
||||
},
|
||||
|
@ -171,7 +155,7 @@ const Row: Component<{ entry: Entry, path?: string[] }> = (props) => {
|
|||
return <Show when={isLeaf(value)} fallback={<Group key={key} entry={value as Entry} path={path} />}>
|
||||
<div class={css.row} use:selectable={{ value, key: k }}>
|
||||
<div class={css.cell}>
|
||||
<input type="checkbox" checked={isSelected()} oninput={() => context.select([k], { append: true })} />
|
||||
<input type="checkbox" checked={isSelected()} oninput={() => context.select([k])} />
|
||||
</div>
|
||||
|
||||
<div class={css.cell}>
|
||||
|
@ -229,5 +213,6 @@ const TextArea: Component<{ key: string, value: string, lang: string, oninput?:
|
|||
spellcheck
|
||||
wrap="soft"
|
||||
onkeyup={onKeyUp}
|
||||
on:pointerdown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
};
|
|
@ -65,7 +65,7 @@ export const SelectionProvider: ParentComponent<{ selection?: SelectionHandler,
|
|||
length,
|
||||
select(selection, { mode = SelectionMode.Normal } = {}) {
|
||||
if (props.multiSelect === true && mode === SelectionMode.Normal) {
|
||||
mode = SelectionMode.Append;
|
||||
mode = SelectionMode.Toggle;
|
||||
}
|
||||
|
||||
setState('selection', existing => {
|
||||
|
@ -238,8 +238,7 @@ export const selectable = (element: HTMLElement, options: Accessor<{ value: obje
|
|||
const append = Boolean(modifier() & Modifier.Control);
|
||||
|
||||
const mode = (() => {
|
||||
if (append) return SelectionMode.Append;
|
||||
if (!withRange && isSelected()) return SelectionMode.Toggle;
|
||||
if (append) return SelectionMode.Toggle;
|
||||
if (withRange) return SelectionMode.Replace;
|
||||
return SelectionMode.Normal;
|
||||
})();
|
||||
|
|
3
src/global.d.ts
vendored
3
src/global.d.ts
vendored
|
@ -1,2 +1,5 @@
|
|||
|
||||
|
||||
interface FileSystemHandle {
|
||||
getUniqueId(): Promise<string>;
|
||||
}
|
|
@ -16,4 +16,12 @@
|
|||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mutated {
|
||||
color: var(--warn);
|
||||
|
||||
&::after {
|
||||
content: ' •';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +1,14 @@
|
|||
import { Menu } from "~/features/menu";
|
||||
import { Sidebar } from "~/components/sidebar";
|
||||
import { Component, createEffect, createMemo, createResource, createSignal, For, onMount, ParentProps, Show } from "solid-js";
|
||||
import { Component, createEffect, createMemo, createResource, createSignal, onMount, ParentComponent, ParentProps } from "solid-js";
|
||||
import { Grid, load, useFiles } from "~/features/file";
|
||||
import { Command, Context, createCommand, Modifier, noop } from "~/features/command";
|
||||
import { GridApi, GridContextType } from "~/features/file/grid";
|
||||
import { GridApi } from "~/features/file/grid";
|
||||
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree, FileEntry } from "~/components/filetree";
|
||||
import css from "./edit.module.css";
|
||||
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree";
|
||||
import { splitAt } from "~/utilities";
|
||||
|
||||
async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): AsyncGenerator<{ handle: FileSystemFileHandle, path: string[], lang: string, entries: Map<string, string> }, void, never> {
|
||||
async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): AsyncGenerator<{ id: string, handle: FileSystemFileHandle, path: string[], lang: string, entries: Map<string, string> }, void, never> {
|
||||
for await (const handle of directory.values()) {
|
||||
if (handle.kind === 'directory') {
|
||||
yield* walk(handle, [...path, handle.name]);
|
||||
|
@ -19,12 +20,13 @@ async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []):
|
|||
continue;
|
||||
}
|
||||
|
||||
const id = await handle.getUniqueId();
|
||||
const file = await handle.getFile();
|
||||
const lang = file.name.split('.').at(0)!;
|
||||
const entries = await load(file);
|
||||
|
||||
if (entries !== undefined) {
|
||||
yield { handle, path, lang, entries };
|
||||
yield { id, handle, path, lang, entries };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -33,10 +35,25 @@ export default function Edit(props: ParentProps) {
|
|||
const filesContext = useFiles();
|
||||
const [root, { mutate, refetch }] = createResource(() => filesContext?.get('root'));
|
||||
const [tree, setFiles] = createSignal<FolderEntry>(emptyFolder);
|
||||
const [columns, setColumns] = createSignal([]);
|
||||
const [rows, setRows] = createSignal<Map<string, { [lang: string]: { value: string, handle: FileSystemFileHandle } }>>(new Map);
|
||||
const [columns, setColumns] = createSignal<string[]>([]);
|
||||
const [rows, setRows] = createSignal<Map<string, Record<string, string>>>(new Map);
|
||||
const [entries, setEntries] = createSignal<Map<string, Record<string, { id: String, value: string, handle: FileSystemFileHandle }>>>(new Map);
|
||||
const [api, setApi] = createSignal<GridApi>();
|
||||
|
||||
const mutatedFiles = createMemo(() => {
|
||||
const mutations = api()?.mutations() ?? [];
|
||||
const files = entries();
|
||||
|
||||
return new Set(mutations
|
||||
.map(mutation => {
|
||||
const [key, lang] = splitAt(mutation.key, mutation.key.lastIndexOf('.'));
|
||||
|
||||
return files.get(key)?.[lang]?.id;
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
});
|
||||
|
||||
// Since the files are stored in indexedDb we need to refetch on the client in order to populate on page load
|
||||
onMount(() => {
|
||||
refetch();
|
||||
|
@ -50,7 +67,7 @@ export default function Edit(props: ParentProps) {
|
|||
const languages = new Set(contents.map(c => c.lang));
|
||||
const template = contents.map(({ lang, handle }) => [lang, { handle, value: '' }]);
|
||||
|
||||
const merged = contents.reduce((aggregate, { handle, path, lang, entries }) => {
|
||||
const merged = contents.reduce((aggregate, { id, handle, path, lang, entries }) => {
|
||||
for (const [key, value] of entries.entries()) {
|
||||
const k = [...path, key].join('.');
|
||||
|
||||
|
@ -58,18 +75,23 @@ export default function Edit(props: ParentProps) {
|
|||
aggregate.set(k, Object.fromEntries(template));
|
||||
}
|
||||
|
||||
aggregate.get(k)![lang] = { handle, value };
|
||||
aggregate.get(k)![lang] = { value, handle, id };
|
||||
}
|
||||
|
||||
return aggregate;
|
||||
}, new Map<string, { [lang: string]: { value: string, handle: FileSystemFileHandle } }>());
|
||||
}, new Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>());
|
||||
|
||||
setFiles({ name: '', kind: 'folder', entries: await Array.fromAsync(fileTreeWalk(directory)) });
|
||||
setFiles({ name: '', id: '', kind: 'folder', entries: await Array.fromAsync(fileTreeWalk(directory)) });
|
||||
setColumns(['key', ...languages]);
|
||||
setRows(merged);
|
||||
setEntries(merged);
|
||||
setRows(new Map(merged.entries().map(([key, langs]) => [key, Object.fromEntries(Object.entries(langs).map(([lang, { value }]) => [lang, value]))] as const)));
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
mutatedFiles()
|
||||
});
|
||||
|
||||
const commands = {
|
||||
open: createCommand('open', async () => {
|
||||
const [fileHandle] = await window.showOpenFilePicker({
|
||||
|
@ -96,7 +118,7 @@ export default function Edit(props: ParentProps) {
|
|||
mutate(directory);
|
||||
}),
|
||||
save: createCommand('save', () => {
|
||||
console.log('save', rows());
|
||||
console.log('save');
|
||||
}, { key: 's', modifier: Modifier.Control }),
|
||||
saveAs: createCommand('save as', (handle?: FileSystemFileHandle) => {
|
||||
console.log('save as ...', handle);
|
||||
|
@ -120,12 +142,6 @@ export default function Edit(props: ParentProps) {
|
|||
}),
|
||||
} as const;
|
||||
|
||||
const mutated = createMemo(() => Object.values(api()?.rows() ?? {}).filter(row => Object.values(row).some(lang => lang.original !== lang.value)));
|
||||
|
||||
createEffect(() => {
|
||||
console.log('KAAS', mutated());
|
||||
});
|
||||
|
||||
return <div class={css.root}>
|
||||
<Context.Root commands={[commands.saveAs]}>
|
||||
<Context.Menu>{
|
||||
|
@ -154,7 +170,11 @@ export default function Edit(props: ParentProps) {
|
|||
|
||||
<Sidebar as="aside" class={css.sidebar}>
|
||||
<Tree entries={tree().entries}>{
|
||||
file => <Context.Handle>{file().name}</Context.Handle>
|
||||
file => {
|
||||
const mutated = createMemo(() => mutatedFiles().has(file().id));
|
||||
|
||||
return <Context.Handle><span classList={{ [css.mutated]: mutated() }}>{file().name}</span></Context.Handle>;
|
||||
}
|
||||
}</Tree>
|
||||
</Sidebar>
|
||||
|
||||
|
|
126
src/utilities.ts
Normal file
126
src/utilities.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
export const splitAt = (subject: string, index: number): readonly [string, string] => {
|
||||
return [subject.slice(0, index), subject.slice(index + 1)] as const;
|
||||
};
|
||||
|
||||
export const debounce = <T extends (...args: any[]) => void>(callback: T, delay: number): T => {
|
||||
let handle: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return (...args: any[]) => {
|
||||
if (handle) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
|
||||
handle = setTimeout(() => callback(...args), delay);
|
||||
};
|
||||
};
|
||||
|
||||
export const deepCopy = <T>(original: T): T => {
|
||||
if (typeof original !== 'object' || original === null || original === undefined) {
|
||||
return original;
|
||||
}
|
||||
|
||||
if (original instanceof Date) {
|
||||
return new Date(original.getTime()) as T;
|
||||
}
|
||||
|
||||
if (original instanceof Array) {
|
||||
return original.map(item => deepCopy(item)) as T;
|
||||
}
|
||||
|
||||
if (original instanceof Set) {
|
||||
return new Set(original.values().map(item => deepCopy(item))) as T;
|
||||
}
|
||||
|
||||
if (original instanceof Map) {
|
||||
return new Map(original.entries().map(([key, value]) => [key, deepCopy(value)])) as T;
|
||||
}
|
||||
|
||||
return Object.assign(
|
||||
Object.create(Object.getPrototypeOf(original)),
|
||||
Object.fromEntries(Object.entries(original).map(([key, value]) => [key, deepCopy(value)]))
|
||||
) as T;
|
||||
}
|
||||
|
||||
type Added = { kind: 'added', value: any };
|
||||
type Updated = { kind: 'updated', value: any, original: any };
|
||||
type Removed = { kind: 'removed' };
|
||||
export type Mutation = { key: string } & (Added | Updated | Removed);
|
||||
|
||||
export function* deepDiff<T1 extends object, T2 extends object>(a: T1, b: T2, path: string[] = []): Generator<Mutation, void, unknown> {
|
||||
if (!isIterable(a) || !isIterable(b)) {
|
||||
console.log('Edge cases', a, b);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [[keyA, valueA], [keyB, valueB]] of zip(entriesOf(a), entriesOf(b))) {
|
||||
if (!keyA && !keyB) {
|
||||
throw new Error('this code should not be reachable, there is a bug with an unhandled/unknown edge case');
|
||||
}
|
||||
|
||||
if (!keyA && keyB) {
|
||||
yield { key: path.concat(keyB.toString()).join('.'), kind: 'added', value: valueB };
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (keyA && !keyB) {
|
||||
// value was added
|
||||
yield { key: path.concat(keyA.toString()).join('.'), kind: 'removed' };
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof valueA == 'object' && typeof valueB == 'object') {
|
||||
yield* deepDiff(valueA, valueB, path.concat(keyA!.toString()));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valueA === valueB) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = path.concat(keyA!.toString()).join('.');
|
||||
|
||||
yield ((): Mutation => {
|
||||
if (valueA === null || valueA === undefined) return { key, kind: 'added', value: valueB };
|
||||
if (valueB === null || valueB === undefined) return { key, kind: 'removed' };
|
||||
|
||||
return { key, kind: 'updated', value: valueB, original: valueA };
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
const isIterable = (subject: object): subject is Iterable<any> => ['boolean', 'undefined', 'null', 'number'].includes(typeof subject) === false;
|
||||
const entriesOf = (subject: object): Iterable<readonly [string | number, any]> => {
|
||||
if (subject instanceof Array) {
|
||||
return subject.entries();
|
||||
}
|
||||
|
||||
if (subject instanceof Map) {
|
||||
return subject.entries();
|
||||
}
|
||||
|
||||
if (subject instanceof Set) {
|
||||
return subject.entries();
|
||||
}
|
||||
|
||||
return Object.entries(subject);
|
||||
};
|
||||
const zip = function* (a: Iterable<readonly [string | number, any]>, b: Iterable<readonly [string | number, any]>): Generator<readonly [[string | number | undefined, any], [string | number | undefined, any]], void, unknown> {
|
||||
const iterA = Iterator.from(a);
|
||||
const iterB = Iterator.from(b);
|
||||
|
||||
while (true) {
|
||||
const { done: doneA, value: entryA = [] } = iterA.next() ?? {};
|
||||
const { done: doneB, value: entryB = [] } = iterB.next() ?? {};
|
||||
|
||||
if (doneA && doneB) {
|
||||
break;
|
||||
}
|
||||
|
||||
yield [entryA, entryB] as const;
|
||||
}
|
||||
}
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue