feat: add folder filtering and improve list view performance
- Added server-side folder filtering to paginated metadata endpoint with support for regular folders and .trash - Improved list view performance by optimizing kind filtering and non-markdown file handling - Updated folder navigation to properly reset other filters (tags, quick links, search) when selecting a folder - Added request ID tracking to prevent stale responses from affecting pagination state - Enhanced list view to show loading
This commit is contained in:
@@ -24,9 +24,9 @@ import { takeUntil } from 'rxjs/operators';
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Search and filters header -->
|
||||
<div class="p-2 border-b border-border dark:border-gray-800 space-y-2">
|
||||
<!-- Unified badges row -->
|
||||
<!-- Kind-only badges row -->
|
||||
<div class="flex flex-wrap items-center gap-1.5 min-h-[1.75rem]">
|
||||
<app-filter-badge *ngFor="let b of filter.badges()"
|
||||
<app-filter-badge *ngFor="let b of badgesKindOnly()"
|
||||
[label]="b.label" [icon]="b.icon" (remove)="filter.removeBadge(b)"></app-filter-badge>
|
||||
</div>
|
||||
<div *ngIf="activeTag() as t" class="flex items-center gap-2 text-xs">
|
||||
@@ -46,15 +46,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="folderFilter() as f" class="flex items-center gap-2 text-xs">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-surface1 dark:bg-card text-main dark:text-main px-2 py-1">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-6l-2-2H5a2 2 0 0 0-2 2z"/></svg>
|
||||
{{ f }}
|
||||
</span>
|
||||
<button type="button" (click)="clearFolderFilter.emit()" class="rounded-full hover:bg-slate-500/10 dark:hover:bg-surface2/10 w-6 h-6 inline-flex items-center justify-center" title="Effacer le dossier">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="text"
|
||||
[value]="query()"
|
||||
(input)="onQuery($any($event.target).value)"
|
||||
@@ -83,10 +75,10 @@ import { takeUntil } from 'rxjs/operators';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Count -->
|
||||
<!-- Count (avoid flashing 0 during initial load) -->
|
||||
<div class="flex items-center gap-1 text-xs text-muted">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="9"/></svg>
|
||||
{{ visibleNotes().length }}
|
||||
{{ (isLoadingMore() && totalLoaded() === 0) ? '…' : visibleNotes().length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,6 +312,11 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
private editorState = inject(EditorStateService);
|
||||
private destroy$ = new Subject<void>();
|
||||
private preservedOffset: number | null = null;
|
||||
private useUnifiedSync = true;
|
||||
private lastSyncKey = signal<string>('');
|
||||
|
||||
// Header shows only kind badges (IMAGE, PDF, VIDEO, etc.)
|
||||
badgesKindOnly = computed(() => (this.filter.badges() || []).filter((b: any) => b?.type === 'kind'));
|
||||
|
||||
@ViewChild(CdkVirtualScrollViewport) viewport?: CdkVirtualScrollViewport;
|
||||
|
||||
@@ -360,11 +357,13 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
// Visible notes with fallback and filters
|
||||
visibleNotes = computed<NoteMetadata[]>(() => {
|
||||
let items = this.paginatedNotes();
|
||||
const dbg = (() => { try { const w: any = (globalThis as any).window; return !!(w && (w.__LIST_DEBUG__ || localStorage.getItem('LIST_DEBUG') === '1')); } catch { return false; } })();
|
||||
let usedFallback = false;
|
||||
const vaultNotes = (() => {
|
||||
try { return this.vault.allNotes() || []; } catch { return []; }
|
||||
})();
|
||||
const byId = new Map<string, any>(vaultNotes.map(n => [n.id, n]));
|
||||
if (dbg) console.log('[List] start', { paginated: (items?.length||0) });
|
||||
if (!items || items.length === 0) {
|
||||
try {
|
||||
const all = this.vault.allNotes();
|
||||
@@ -402,6 +401,35 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
return !fp.startsWith('.trash/') && !fp.includes('/.trash/');
|
||||
});
|
||||
}
|
||||
if (dbg) console.log('[List] after folder filter', { folder, count: items.length });
|
||||
|
||||
// Secondary fallback: if folder filter produced 0 items, rebuild from vault notes
|
||||
if (items.length === 0 && folder) {
|
||||
try {
|
||||
usedFallback = true;
|
||||
const all = vaultNotes; // already loaded above
|
||||
let rebuilt = (all || []).map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
filePath: n.filePath,
|
||||
createdAt: (n as any).createdAt,
|
||||
updatedAt: (n as any).updatedAt || (n.mtime ? new Date(n.mtime).toISOString() : '')
|
||||
}));
|
||||
// Apply same folder/trash constraint
|
||||
if (folder === '.trash') {
|
||||
rebuilt = rebuilt.filter(n => {
|
||||
const fp = (n.filePath || '').toLowerCase().replace(/\\/g, '/');
|
||||
return fp.startsWith('.trash/') || fp.includes('/.trash/');
|
||||
});
|
||||
} else {
|
||||
rebuilt = rebuilt.filter(n => {
|
||||
const op = (n.filePath || '').toLowerCase().replace(/^\/+|\/+$/g, '');
|
||||
return op === folder || op.startsWith(folder + '/');
|
||||
});
|
||||
}
|
||||
items = rebuilt;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// If Tag or Quick is active, use full vault notes (markdown) to ensure tags/frontmatter are present
|
||||
const quickKey = String(this.quickLinkFilter() || '').toLowerCase();
|
||||
@@ -433,31 +461,45 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (dbg) console.log('[List] after tag/quick normalization', { tagActive, quickKey, count: items.length });
|
||||
|
||||
// Kind filters (FilterService multi-kinds first; fallback to single kindFilter)
|
||||
// Kind filters
|
||||
const kinds = this.filter.kinds();
|
||||
const urlKind = this.kindFilter();
|
||||
let allowedKinds = new Set<string>(kinds.length > 0 ? kinds : (urlKind && urlKind !== 'all' ? [urlKind] : []));
|
||||
|
||||
// Folder/Trash views: if kinds are explicitly selected, honor them; otherwise default to 'all'
|
||||
const folderActive = !!folder;
|
||||
const quickActive = !!quickKey;
|
||||
// tagActive already computed above
|
||||
// Do not override allowedKinds in folder view; when none selected and no tag/quick, the filter below treats size 0 as 'all'
|
||||
// IMPORTANT: Tags/Quick enforce markdown-only regardless of kind filters
|
||||
let allowedKinds: Set<string>;
|
||||
if (kinds.length > 0) {
|
||||
// explicit multi-kind selection from chips
|
||||
allowedKinds = new Set<string>(kinds);
|
||||
} else if (folderActive) {
|
||||
// In Folders view with no chips selected -> 'Tout' => no restriction by kind
|
||||
allowedKinds = new Set<string>();
|
||||
} else if (urlKind && urlKind !== 'all') {
|
||||
// fallback to URL kind when not in folder view
|
||||
allowedKinds = new Set<string>([urlKind]);
|
||||
} else {
|
||||
// default: no restriction
|
||||
allowedKinds = new Set<string>();
|
||||
}
|
||||
|
||||
// Tags/Quick enforce markdown-only regardless of kind chips
|
||||
if (tagActive || quickActive) {
|
||||
allowedKinds = new Set<string>(['markdown']);
|
||||
}
|
||||
if (dbg) console.log('[List] kinds', { kinds, urlKind, allowed: Array.from(allowedKinds) });
|
||||
|
||||
if (allowedKinds.size > 0) {
|
||||
items = items.filter(n => Array.from(allowedKinds).some(k => this.matchesKind(n.filePath, k as any)));
|
||||
}
|
||||
if (dbg) console.log('[List] after kind filter', { count: items.length });
|
||||
|
||||
// Query filtering (always apply client-side as extra guard)
|
||||
const q = (this.q() || '').toLowerCase().trim();
|
||||
if (q) {
|
||||
items = items.filter(n => (n.title || '').toLowerCase().includes(q) || (n.filePath || '').toLowerCase().includes(q));
|
||||
}
|
||||
if (dbg) console.log('[List] after query filter', { q, count: items.length });
|
||||
|
||||
// Tag and Quick Link filters using vault metadata when available
|
||||
const urlTag2 = (this.tagFilter() || '').toLowerCase();
|
||||
@@ -484,43 +526,39 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
// If allowed kinds include any non-markdown type OR no kinds selected at all (default 'all'),
|
||||
// ensure those files appear even if pagination didn't include them (server may return only markdown)
|
||||
const needMergeForKinds = (allowedKinds.size > 0 && Array.from(allowedKinds).some(k => k !== 'markdown'))
|
||||
|| (allowedKinds.size === 0 && !quickActive && !tagActive); // default 'all' and no quick/tag constraint
|
||||
if (needMergeForKinds && !usedFallback) {
|
||||
// de-duplicate by filePath (case-insensitive) to avoid duplicates between Meili and FS
|
||||
|| (allowedKinds.size === 0 && !quickActive && !tagActive);
|
||||
if (needMergeForKinds) {
|
||||
const presentPath = new Set(items.map(n => String(n.filePath || '').toLowerCase().replace(/\\/g, '/')));
|
||||
for (const full of vaultNotes) {
|
||||
const t = this.fileTypes.getViewerType(full.filePath, full.rawContent ?? full.content ?? '');
|
||||
// Only merge NON-markdown files to avoid duplicating markdown already provided by Meilisearch
|
||||
if (t === 'markdown') continue;
|
||||
const allowByKind = allowedKinds.size === 0 ? true : allowedKinds.has(t);
|
||||
const fullPathLc = String(full.filePath || '').toLowerCase().replace(/\\/g, '/');
|
||||
if (allowByKind && !presentPath.has(fullPathLc)) {
|
||||
// Apply same folder filter and tag/quick constraints
|
||||
const fp = (full.filePath || '').toLowerCase().replace(/\\/g, '/');
|
||||
const op = (full.filePath || '').toLowerCase().replace(/^\/+|\/+$/g, '');
|
||||
const includeByFolder = folder
|
||||
? (folder === '.trash'
|
||||
? (fp.startsWith('.trash/') || fp.includes('/.trash/'))
|
||||
: (op === folder || op.startsWith(folder + '/')))
|
||||
: (!fp.startsWith('.trash/') && !fp.includes('/.trash/'));
|
||||
if (!includeByFolder) continue;
|
||||
const ntags: string[] = Array.isArray(full.tags) ? full.tags.map((x: string) => (x || '').toLowerCase()) : [];
|
||||
if (urlTag && !ntags.includes(urlTag)) continue;
|
||||
let okLocal = true; for (const t of localTags) { if (!ntags.includes(t)) { okLocal = false; break; } }
|
||||
if (!okLocal) continue;
|
||||
if (quickKey2) {
|
||||
const fm = full.frontmatter || {};
|
||||
if (fm[quickKey2] !== true) continue;
|
||||
}
|
||||
if (q) {
|
||||
const titleLc = (full.title || '').toLowerCase();
|
||||
const pathLc = (full.filePath || '').toLowerCase();
|
||||
if (!titleLc.includes(q) && !pathLc.includes(q)) continue;
|
||||
}
|
||||
items.push({ id: full.id, title: full.title, filePath: full.filePath, createdAt: (full as any).createdAt, updatedAt: (full as any).updatedAt || (full.mtime ? new Date(full.mtime).toISOString() : '') });
|
||||
presentPath.add(fullPathLc);
|
||||
const metas = (() => { try { return this.vault.allFilesMetadata() || []; } catch { return []; } })();
|
||||
let merged = 0;
|
||||
for (const meta of metas) {
|
||||
const filePath = (meta.path || (meta as any).filePath || '').toLowerCase().replace(/\\/g, '/');
|
||||
if (!filePath) continue;
|
||||
const viewer = this.fileTypes.getViewerType(filePath, '');
|
||||
if (viewer === 'markdown') continue;
|
||||
const allowByKind = allowedKinds.size === 0 ? true : allowedKinds.has(viewer);
|
||||
if (!allowByKind) continue;
|
||||
if (presentPath.has(filePath)) continue;
|
||||
const op = filePath.replace(/^\/+|\/+$/g, '');
|
||||
const includeByFolder = folder
|
||||
? (folder === '.trash'
|
||||
? (filePath.startsWith('.trash/') || filePath.includes('/.trash/'))
|
||||
: (op === folder || op.startsWith(folder + '/')))
|
||||
: (!filePath.startsWith('.trash/') && !filePath.includes('/.trash/'));
|
||||
if (!includeByFolder) continue;
|
||||
if (q) {
|
||||
const titleLc = String(meta.title || '').toLowerCase();
|
||||
if (!titleLc.includes(q) && !filePath.includes(q)) continue;
|
||||
}
|
||||
const id = String((meta as any).id || filePath);
|
||||
const title = String(meta.title || filePath.split('/').pop() || filePath);
|
||||
const createdAt = (meta as any).createdAt || undefined;
|
||||
const updatedAt = (meta as any).updatedAt || undefined;
|
||||
items.push({ id, title, filePath: filePath, createdAt: createdAt as any, updatedAt: updatedAt as any });
|
||||
presentPath.add(filePath);
|
||||
merged++;
|
||||
}
|
||||
if (dbg) console.log('[List] merged non-markdown from meta index', { merged, before: items.length - merged, after: items.length });
|
||||
}
|
||||
|
||||
// Final de-duplication by filePath (case-insensitive) to avoid duplicates pointing to same file
|
||||
@@ -531,6 +569,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
if (!byPath.has(key)) byPath.set(key, it);
|
||||
}
|
||||
items = Array.from(byPath.values());
|
||||
if (dbg) console.log('[List] final', { count: items.length });
|
||||
|
||||
// Sorting (title/created/updated) like old list
|
||||
const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0;
|
||||
@@ -638,12 +677,6 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
private syncQuery = effect(() => {
|
||||
this.q.set(this.query() || '');
|
||||
// If external query changes (e.g., URL/state), refresh pagination to match
|
||||
const current = this.paginationService.getSearchTerm();
|
||||
const next = this.query() || '';
|
||||
if (current !== next) {
|
||||
this.paginationService.search(next);
|
||||
}
|
||||
});
|
||||
|
||||
private syncTagFromStore = effect(() => {
|
||||
@@ -654,10 +687,66 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.activeTag.set(this.store.get());
|
||||
});
|
||||
|
||||
// Sync folder filter with PaginationService
|
||||
private syncFolderFilter = effect(() => {
|
||||
if (this.useUnifiedSync) { return; }
|
||||
const folder = this.folderFilter();
|
||||
const currentFolder = this.paginationService.getFolderFilter();
|
||||
// Only reload if folder actually changed to avoid infinite loops
|
||||
if (folder !== currentFolder) {
|
||||
console.log('[PaginatedNotesList] Folder filter changed:', { from: currentFolder, to: folder });
|
||||
this.paginationService.setFolderFilter(folder).catch(err => {
|
||||
console.error('[PaginatedNotesList] Failed to set folder filter:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sync tag filter with PaginationService
|
||||
private syncTagFilterToPagination = effect(() => {
|
||||
if (this.useUnifiedSync) { return; }
|
||||
const tag = this.tagFilter();
|
||||
const currentTag = this.paginationService.getTagFilter();
|
||||
// Only reload if tag actually changed
|
||||
if (tag !== currentTag) {
|
||||
console.log('[PaginatedNotesList] Tag filter changed:', { from: currentTag, to: tag });
|
||||
this.paginationService.setTagFilter(tag).catch(err => {
|
||||
console.error('[PaginatedNotesList] Failed to set tag filter:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sync quick link filter with PaginationService
|
||||
private syncQuickLinkFilter = effect(() => {
|
||||
if (this.useUnifiedSync) { return; }
|
||||
const quick = this.quickLinkFilter();
|
||||
const currentQuick = this.paginationService.getQuickLinkFilter();
|
||||
// Only reload if quick link actually changed
|
||||
if (quick !== currentQuick) {
|
||||
console.log('[PaginatedNotesList] Quick link filter changed:', { from: currentQuick, to: quick });
|
||||
this.paginationService.setQuickLinkFilter(quick).catch(err => {
|
||||
console.error('[PaginatedNotesList] Failed to set quick link filter:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Unified synchronization effect: apply search + folder + tag + quick together
|
||||
private syncAllFilters = effect(() => {
|
||||
if (!this.useUnifiedSync) { return; }
|
||||
const search = this.query() || '';
|
||||
const folder = this.folderFilter();
|
||||
const tag = this.tagFilter();
|
||||
const quick = this.quickLinkFilter();
|
||||
const key = `${search}||${folder ?? ''}||${tag ?? ''}||${quick ?? ''}`;
|
||||
if (this.lastSyncKey() === key) return;
|
||||
this.lastSyncKey.set(key);
|
||||
this.paginationService.loadInitial(search, folder, tag, quick).catch(err => {
|
||||
console.error('[PaginatedNotesList] Failed to load initial with unified filters:', err);
|
||||
});
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
// Load initial page with incoming query
|
||||
this.paginationService.loadInitial(this.query() || '');
|
||||
// No-op: effects (syncQuery, syncFolderFilter, syncTagFilterToPagination, syncQuickLinkFilter) perform the initial load
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -750,13 +750,20 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
|
||||
|
||||
onFolderSelected(path: string) {
|
||||
this.folderFilter = path || null;
|
||||
this.tagFilter = null; // clear tag when focusing folder
|
||||
// Reset other filters and search when focusing a folder to prevent residual constraints
|
||||
this.tagFilter = null;
|
||||
this.quickLinkFilter = null;
|
||||
this.listQuery = '';
|
||||
try { this.filters.clearKinds(); } catch {}
|
||||
try { this.filters.clearTags(); } catch {}
|
||||
this.autoSelectFirstNote();
|
||||
if (this.responsive.isMobile() || this.responsive.isTablet()) {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
// Reflect folder in URL
|
||||
if (path) {
|
||||
// Clear search in URL to avoid residual query re-applying via URL effect
|
||||
try { this.urlState.updateSearch(''); } catch {}
|
||||
this.urlState.filterByFolder(path);
|
||||
} else {
|
||||
this.urlState.resetState();
|
||||
@@ -765,11 +772,18 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
|
||||
|
||||
onFolderSelectedFromDrawer(path: string) {
|
||||
this.folderFilter = path || null;
|
||||
this.tagFilter = null; // clear tag when focusing folder
|
||||
// Reset other filters and search when focusing a folder to prevent residual constraints
|
||||
this.tagFilter = null;
|
||||
this.quickLinkFilter = null;
|
||||
this.listQuery = '';
|
||||
try { this.filters.clearKinds(); } catch {}
|
||||
try { this.filters.clearTags(); } catch {}
|
||||
this.autoSelectFirstNote();
|
||||
this.mobileNav.setActiveTab('list');
|
||||
this.mobileNav.sidebarOpen.set(false);
|
||||
if (path) {
|
||||
// Clear search in URL to avoid residual query re-applying via URL effect
|
||||
try { this.urlState.updateSearch(''); } catch {}
|
||||
this.urlState.filterByFolder(path);
|
||||
} else {
|
||||
this.urlState.resetState();
|
||||
|
||||
@@ -30,6 +30,13 @@ export class PaginationService {
|
||||
private totalItems = signal(0);
|
||||
// Notifier fired BEFORE pages reset, so views can capture scroll anchors
|
||||
private willReset = signal(0);
|
||||
|
||||
// Filter state for server-side filtering
|
||||
private folderFilter = signal<string | null>(null);
|
||||
private tagFilter = signal<string | null>(null);
|
||||
private quickLinkFilter = signal<string | null>(null);
|
||||
// Track the latest in-flight request; responses from older requests will be ignored
|
||||
private activeRequestId = signal(0);
|
||||
|
||||
// Computed properties
|
||||
readonly allItems = computed(() => {
|
||||
@@ -49,8 +56,16 @@ export class PaginationService {
|
||||
readonly onWillReset = this.willReset;
|
||||
|
||||
// Load initial page
|
||||
async loadInitial(search = ''): Promise<void> {
|
||||
async loadInitial(search = '', folder: string | null = null, tag: string | null = null, quick: string | null = null): Promise<void> {
|
||||
const dbg = this.debugOn();
|
||||
if (dbg) console.log('[Pagination] loadInitial', { search, folder, tag, quick });
|
||||
// Bump request id to invalidate any previous in-flight requests
|
||||
const reqId = (this.activeRequestId() + 1);
|
||||
this.activeRequestId.set(reqId);
|
||||
this.searchTerm.set(search);
|
||||
this.folderFilter.set(folder);
|
||||
this.tagFilter.set(tag);
|
||||
this.quickLinkFilter.set(quick);
|
||||
// Notify listeners that a reset is imminent
|
||||
this.willReset.update(v => v + 1);
|
||||
this.pages.set(new Map());
|
||||
@@ -58,12 +73,16 @@ export class PaginationService {
|
||||
this.hasMorePages.set(true);
|
||||
this.totalItems.set(0);
|
||||
|
||||
await this.loadNextPage();
|
||||
await this.loadNextPage(reqId);
|
||||
}
|
||||
|
||||
// Load next page
|
||||
async loadNextPage(): Promise<void> {
|
||||
if (this.isLoading() || !this.hasMorePages()) return;
|
||||
async loadNextPage(requestId?: number): Promise<void> {
|
||||
// Do not block a new load for the latest request id even if a previous request is still loading
|
||||
const expectedReqPre = this.activeRequestId();
|
||||
const ridPre = (requestId == null) ? expectedReqPre : requestId;
|
||||
if (this.isLoading() && ridPre !== expectedReqPre) return;
|
||||
if (!this.hasMorePages()) return;
|
||||
|
||||
this.isLoading.set(true);
|
||||
|
||||
@@ -76,11 +95,30 @@ export class PaginationService {
|
||||
if (this.currentCursor() !== null) {
|
||||
params.cursor = this.currentCursor();
|
||||
}
|
||||
|
||||
// Add filter params if present
|
||||
const folder = this.folderFilter();
|
||||
const tag = this.tagFilter();
|
||||
const quick = this.quickLinkFilter();
|
||||
|
||||
if (folder) params.folder = folder;
|
||||
if (tag) params.tag = tag;
|
||||
if (quick) params.quick = quick;
|
||||
const dbg = this.debugOn();
|
||||
if (dbg) console.log('[Pagination] request', { params, rid: ridPre });
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<PaginationResponse>('/api/vault/metadata/paginated', { params })
|
||||
);
|
||||
|
||||
// Ignore stale responses (default to current active id if none provided)
|
||||
const expectedReq = this.activeRequestId();
|
||||
const rid = (requestId == null) ? expectedReq : requestId;
|
||||
if (rid !== expectedReq) {
|
||||
if (dbg) console.log('[Pagination] stale', { rid, expectedReq });
|
||||
return;
|
||||
}
|
||||
|
||||
// Add page to cache
|
||||
const pageIndex = this.pages().size;
|
||||
this.pages.update(pages => {
|
||||
@@ -93,8 +131,11 @@ export class PaginationService {
|
||||
this.currentCursor.set(response.nextCursor);
|
||||
this.hasMorePages.set(response.hasMore);
|
||||
this.totalItems.set(response.total);
|
||||
if (dbg) console.log('[Pagination] response', { count: response.items.length, total: response.total, nextCursor: response.nextCursor, hasMore: response.hasMore });
|
||||
|
||||
} catch (error) {
|
||||
const dbg = this.debugOn();
|
||||
if (dbg) console.error('[Pagination] error', error);
|
||||
console.error('[PaginationService] Failed to load page:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -104,7 +145,40 @@ export class PaginationService {
|
||||
|
||||
// Search with new term
|
||||
async search(term: string): Promise<void> {
|
||||
await this.loadInitial(term);
|
||||
await this.loadInitial(term, this.folderFilter(), this.tagFilter(), this.quickLinkFilter());
|
||||
}
|
||||
|
||||
// Set folder filter and reload
|
||||
async setFolderFilter(folder: string | null): Promise<void> {
|
||||
await this.loadInitial(this.searchTerm(), folder, this.tagFilter(), this.quickLinkFilter());
|
||||
}
|
||||
|
||||
// Set tag filter and reload
|
||||
async setTagFilter(tag: string | null): Promise<void> {
|
||||
await this.loadInitial(this.searchTerm(), this.folderFilter(), tag, this.quickLinkFilter());
|
||||
}
|
||||
|
||||
// Set quick link filter and reload
|
||||
async setQuickLinkFilter(quick: string | null): Promise<void> {
|
||||
await this.loadInitial(this.searchTerm(), this.folderFilter(), this.tagFilter(), quick);
|
||||
}
|
||||
|
||||
// Get current filters
|
||||
getFolderFilter(): string | null {
|
||||
return this.folderFilter();
|
||||
}
|
||||
|
||||
getTagFilter(): string | null {
|
||||
return this.tagFilter();
|
||||
}
|
||||
|
||||
getQuickLinkFilter(): string | null {
|
||||
return this.quickLinkFilter();
|
||||
}
|
||||
|
||||
// Expose lightweight loading state if UI needs to avoid showing misleading 0 while first page loads
|
||||
getIsLoading(): boolean {
|
||||
return this.isLoading();
|
||||
}
|
||||
|
||||
// Invalidate cache (after file changes)
|
||||
@@ -126,4 +200,11 @@ export class PaginationService {
|
||||
getTotalItems(): number {
|
||||
return this.totalItems();
|
||||
}
|
||||
|
||||
private debugOn(): boolean {
|
||||
try {
|
||||
const w: any = (globalThis as any).window;
|
||||
return !!(w && (w.__LIST_DEBUG__ || localStorage.getItem('LIST_DEBUG') === '1'));
|
||||
} catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +464,10 @@ export class VaultService implements OnDestroy {
|
||||
return this.metaByPathIndex.get(p);
|
||||
}
|
||||
|
||||
allFilesMetadata(): FileMetadata[] {
|
||||
return Array.from(this.metaByPathIndex.values());
|
||||
}
|
||||
|
||||
async updateNoteTags(noteId: string, tags: string[]): Promise<boolean> {
|
||||
const note = this.getNoteById(noteId);
|
||||
if (!note?.filePath) return false;
|
||||
|
||||
Reference in New Issue
Block a user