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 71x 71x 71x 71x 71x 71x 71x 71x 71x 12x 37x 14x 47x 34x 34x 34x 34x 34x 34x 34x 57x 9x 6x 6x 57x 57x 85x 83x 83x 85x 83x 83x 83x 83x 79x 78x 78x 78x 78x 525x 78x 4x 3x 3x 83x 525x 525x 525x 525x 525x 525x 210x 210x 210x 210x 210x 2x 525x 525x 525x 105x 105x 11x 9x 2x 11x | 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?.();
});
}
}
|