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?.();
});
}
}
|