All files / src/components document-list.ts

100% Statements 79/79
100% Branches 27/27
100% Functions 16/16
100% Lines 68/68

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150          4x   4x                                     87x 87x 87x 87x   87x 87x     87x 87x 87x         12x         46x       14x       76x         46x 46x 46x 46x 46x 46x 46x         73x 9x 6x 6x   73x 73x         106x 104x 104x 106x       104x 104x 104x 104x 100x 99x 99x 99x 99x 543x   99x   4x 3x 3x   104x         543x 543x   543x 543x 543x 543x 210x 210x 210x 210x 210x 2x     543x       543x 543x       123x       123x 21x 19x   2x   21x        
import { Notice } from "obsidian";
import type { DocumentEntry, DocumentsResponse } from "../types";
import { MESSAGES } from "../locales/en";
import { relativeTimeFromIso } from "../utils";
 
export const DOCUMENT_PAGE_SIZE = 200;
/** Distance from the bottom of the scroll box at which the next page loads. */
const SCROLL_BOTTOM_THRESHOLD_PX = 200;
 
export type DocumentPageFetcher = (limit: number, offset: number) => Promise<DocumentsResponse>;
 
export interface DocumentListOptions {
    /** Renders a checkbox per row and tracks the checked filenames. */
    selectable?: boolean;
    onSelectionChange?: () => void;
    /** Makes each filename a link that hands the document to the caller. */
    onOpen?: (doc: DocumentEntry) => void;
    /** Runs after every page attempt with whether the fetch succeeded. */
    onPage?: (loaded: boolean) => void;
}
 
/** Paged list of indexed documents, shared by the Documents modal and the Status modal. */
export class DocumentList {
    private readonly containerEl: HTMLElement;
    private readonly fetchPage: DocumentPageFetcher;
    private readonly options: DocumentListOptions;
    private offset = 0;
    private totalCount = 0;
    private more = true;
    private fetching = false;
    /** Bumped by reset() so a page that lands for an earlier query is dropped. */
    private generation = 0;
    private selectedNames = new Set<string>();
 
    constructor(containerEl: HTMLElement, fetchPage: DocumentPageFetcher, options: DocumentListOptions = {}) {
        this.containerEl = containerEl;
        this.fetchPage = fetchPage;
        this.options = options;
    }
 
    /** Documents the server reports for the current query. */
    get total(): number {
        return this.totalCount;
    }
 
    /** Documents rendered so far, which is also the offset of the next page. */
    get loaded(): number {
        return this.offset;
    }
 
    get hasMore(): boolean {
        return this.more;
    }
 
    get selected(): string[] {
        return Array.from(this.selectedNames);
    }
 
    /** Drops the rendered rows and starts again from the first page. */
    reset(): void {
        this.generation += 1;
        this.offset = 0;
        this.totalCount = 0;
        this.more = true;
        this.fetching = false;
        this.selectedNames.clear();
        this.containerEl.empty();
    }
 
    /** Loads the next page whenever *scrollEl* scrolls near its bottom. Returns the unbind function. */
    bindScroll(scrollEl: HTMLElement): () => void {
        const onScroll = (): void => {
            if (this.fetching || !this.more) return;
            const { scrollTop, clientHeight, scrollHeight } = scrollEl;
            if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD_PX) void this.loadMore();
        };
        scrollEl.addEventListener("scroll", onScroll);
        return () => scrollEl.removeEventListener("scroll", onScroll);
    }
 
    /** Appends the next page. Returns false when the fetch fails. */
    async loadMore(): Promise<boolean> {
        if (this.fetching || !this.more) return true;
        const loaded = await this.fetchNextPage();
        this.options.onPage?.(loaded);
        return loaded;
    }
 
    private async fetchNextPage(): Promise<boolean> {
        this.fetching = true;
        const generation = this.generation;
        try {
            const response = await this.fetchPage(DOCUMENT_PAGE_SIZE, this.offset);
            if (generation !== this.generation) return true;
            this.totalCount = response.total;
            this.offset += response.documents.length;
            this.more = response.has_more;
            for (const doc of response.documents) {
                this.renderRow(doc);
            }
            return true;
        } catch {
            if (generation !== this.generation) return true;
            new Notice(MESSAGES.ERROR_LOAD_DOCUMENTS);
            return false;
        } finally {
            if (generation === this.generation) this.fetching = false;
        }
    }
 
    private renderRow(doc: DocumentEntry): void {
        const row = this.containerEl.createDiv({ cls: "lilbee-documents-row" });
        if (this.options.selectable) this.renderCheckbox(row, doc);
 
        const nameEl = row.createDiv({ cls: "lilbee-documents-row-name", text: doc.filename });
        nameEl.setAttribute("title", doc.filename);
        const { onOpen } = this.options;
        if (onOpen) {
            nameEl.addClass("lilbee-documents-row-link");
            nameEl.setAttribute("role", "link");
            nameEl.setAttribute("tabindex", "0");
            nameEl.addEventListener("click", () => onOpen(doc));
            nameEl.addEventListener("keydown", (event: KeyboardEvent) => {
                if (event.key === "Enter") onOpen(doc);
            });
        }
        row.createDiv({
            cls: "lilbee-documents-row-chunks",
            text: MESSAGES.LABEL_DOCUMENT_CHUNKS(doc.chunk_count),
        });
        const dateEl = row.createDiv({ cls: "lilbee-documents-row-date", text: relativeTimeFromIso(doc.ingested_at) });
        if (doc.ingested_at) dateEl.setAttribute("title", doc.ingested_at);
    }
 
    private renderCheckbox(row: HTMLElement, doc: DocumentEntry): void {
        const checkbox = row.createEl("input", {
            cls: "lilbee-documents-checkbox",
            attr: { type: "checkbox" },
        });
        checkbox.addEventListener("change", () => {
            if (checkbox.checked) {
                this.selectedNames.add(doc.filename);
            } else {
                this.selectedNames.delete(doc.filename);
            }
            this.options.onSelectionChange?.();
        });
    }
}