Files
Oxicloud/static/js/components/resourceList.js
T
Claude 8dab135090 Add embedded Tantivy full-text content search
/api/search now finds files by CONTENT as well as by name: BM25-ranked
matches over extracted text (PDF, Office OOXML/ODF, plain text/code)
with typo-tolerant fuzzy terms and search-as-you-type prefix matching,
served from an embedded Tantivy index at {storage}/.search-index.

Pipeline (all off the request path, mirroring tree-etag + thumbnails):
- statement triggers on storage.files append to a durable dirty queue
  (storage.search_index_dirty) - every write surface (REST, WebDAV,
  NextCloud, WOPI, trash) is covered, crash-safe by construction
- ContentIndexWorker drains the queue on the maintenance pool, extracts
  text once per unique BLAKE3 blob (storage.blob_extracted_text cache:
  N copies = 1 extraction, renames/moves = 0 re-extraction) and applies
  batched single-writer Tantivy commits; queue rows are deleted only
  after the commit succeeds (at-least-once, idempotent upserts)
- the index is a derived artifact: a version-marker mismatch wipes and
  reseeds it from Postgres, which remains the single source of truth

SearchService merges content hits into the existing name search: hits
are hydrated through ONE SQL round-trip that re-applies user scope,
trash state and every active filter (a stale index id can never leak),
scored below name matches, and returned with a plain-text snippet and
a match_source field. Index failure or
OXICLOUD_ENABLE_CONTENT_SEARCH=false degrades to name-only search; a
discard-only janitor keeps the trigger-fed queue bounded while disabled.

The frontend renders the snippet under the file name in list view.

New dependencies: tantivy 0.26, zip 8.6 (deflate only), pdf-extract 0.10.

https://claude.ai/code/session_01Sc7F4xbo83YbFAQ4xEeDrX
2026-06-11 15:16:03 +00:00

1004 lines
44 KiB
JavaScript

/**
* ResourceListComponent — generic grid / list renderer for files and folders.
*
* Each view that shows a list of resources (SharedWithMe, Favorites, Recent,
* and the main file manager) creates its own component instance with a config
* that enables only the features the view needs.
*
* The component is responsible for:
* - Creating .file-item DOM nodes (folders first, then files)
* - Injecting optional swimlane dividers via a `groupFn`
* - Scoped event delegation (one listener per instance, never global)
* - Reporting events back to the view through callbacks
*
* The component does NOT own: context menus, multi-select toolbar, navigation
* state, or thumbnail generation queues — those remain in the calling module
* and are reached through the config callbacks.
*/
// @ts-check
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { systemUsers } from '../model/systemUsers.js';
import { buildResourceIcon } from './resourceIcon.js';
import { createUserVignette } from './userVignette.js';
/**
* @import {FileItem, FolderItem} from '../core/types.js'
*/
/**
* Reusable swimlane key for the client-side "just added" lane that
* `addItem()` opens at the top of the list in grouped views. Distinct
* from any natural group key the server might produce so the lookup
* can't collide with a real bucket whose label happens to read "New".
* @type {string}
*/
const JUST_ADDED_KEY = '__oxicloud_just_added__';
/**
* @typedef {Object} CustomAction
* @property {string} iconHtml - Inner HTML for the button icon (e.g. `<i class="fas fa-undo"></i>`).
* @property {string} [labelKey] - i18n key used for the button's `title` / `aria-label`.
* @property {string} [className] - Extra CSS class(es) appended to `btn-action`.
* @property {(item: FileItem|FolderItem) => (void|Promise<void>)} onClick
*/
/**
* @typedef {Object} ResourceListConfig
*
* Feature flags
* @property {boolean} [selectable=true] - Show per-item checkboxes and enable selection.
* @property {boolean} [showFavorite=true] - Show the favorite-star button on each item.
* @property {boolean} [showOwner=false] - Show the owner column initially.
* @property {boolean} [showShareBadge=true] - Show the shared-resource badge on items.
* @property {boolean} [draggable=false] - Mark items as draggable (HTML attribute).
* @property {boolean} [showContextMenu=true] - Enable the three-dots button and right-click menu.
* @property {boolean} [showType=true] - Render the Type column.
* @property {boolean} [showPath=false] - Render the Path column (CSS hides it in grid mode).
*
* Appearance
* @property {string} [itemModifierClass] - Extra CSS class applied to every .file-item
* (e.g. 'favorite-item', 'recent-item').
* @property {string} [dateField='modified_at'] - Which date field to display in the date column.
* @property {string} [dateLabel] - Column header label for the date column (i18n key).
* @property {(value: string | number | Date | null | undefined) => string} [dateFormatter]
* Override the date-cell formatter. Defaults to `formatDateTime`. Pass
* `formatDaysRemaining` for the Trash view to surface remaining lifetime.
*
* State providers (called at item-creation time)
* @property {(id: string, type: 'file'|'folder') => boolean} [isFavorite]
* @property {(id: string, type: 'file'|'folder') => boolean} [isShared]
*
* Callbacks (all optional; the component silently skips missing ones)
* @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onOpen]
* Called when the user clicks an item (not a button inside it).
* @property {(item: FileItem|FolderItem) => Promise<void>} [onFavoriteToggle]
* Called when the user clicks the favorite-star button.
* @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onContextMenu]
* Called for the three-dots button click and right-click.
* @property {(item: FileItem|FolderItem) => void} [onShareBadgeClick]
* Called when the user clicks the shared badge. Falls back to onContextMenu if absent.
* @property {(selected: Array<FileItem|FolderItem>) => void} [onSelectionChange]
* Called whenever the selection set changes.
*
* Per-section inline actions
* @property {CustomAction[]} [customActions]
* Extra buttons rendered in the action cell (always visible, both grid and list view).
* Use this for section-specific verbs like restore / permanently-delete on trash.
*/
export class ResourceListComponent {
/**
* @param {HTMLElement} container - The element that will contain .file-item nodes.
* @param {ResourceListConfig} config
*/
constructor(container, config) {
this._container = container;
/** @type {Required<Pick<ResourceListConfig,'selectable'|'showFavorite'|'showOwner'|'showShareBadge'|'draggable'|'showContextMenu'|'showType'|'showPath'|'dateField'>> & ResourceListConfig} */
this._cfg = {
selectable: true,
showFavorite: true,
showOwner: false,
showShareBadge: true,
draggable: false,
showContextMenu: true,
showType: true,
showPath: false,
dateField: 'modified_at',
...config
};
/** Items registered with this instance, keyed by id. */
/** @type {Map<string, FileItem|FolderItem>} */
this._items = new Map();
/** IDs of currently selected items. */
/** @type {Set<string>} */
this._selected = new Set();
/** Index of the last clicked item — used for shift-click range selection. */
this._lastClickedIndex = -1;
/**
* The grouping key of the last rendered item — persisted across
* `append()` calls so load-more pages don't insert a redundant header
* when the first item of the new page shares a group with the last
* item of the previous page.
* `undefined` means no items have been rendered yet (reset in `render()`).
* @type {string|null|undefined}
*/
this._lastGroupKey = undefined;
/**
* The live DOM group wrapper of the last rendered swimlane — persisted
* across `append()` calls so load-more items that continue the same
* group are appended into the existing card rather than starting a new one.
* @type {HTMLElement|null}
*/
this._lastGroupEl = null;
/**
* Live swimlane wrappers currently in the DOM, keyed by group key —
* lets `_findLaneByKey()` resolve in O(1) instead of a container-wide
* `querySelector` per lookup. Kept in sync with the DOM: entries are
* added where lanes are created (`_appendItems`,
* `_ensureJustAddedLane`) and the map is cleared on the full-container
* wipes in `render()` / `clear()`; lanes are never removed
* individually anywhere else.
* @type {Map<string, HTMLElement>}
*/
this._lanes = new Map();
/**
* Optional grouping-key resolver stored between `render()` / `append()`
* calls so `addItem()` can place a new row in the correct swimlane
* without the caller having to re-supply it. `undefined` means the
* current view is flat (no group-by); `null` is never stored — only
* function or `undefined`.
* @type {((item: FileItem|FolderItem) => string|null) | undefined}
*/
this._groupFn = undefined;
/**
* Optional label-resolver stored between `render()` and `append()` calls.
* @type {((key: string) => string) | undefined}
*/
this._groupLabelFn = undefined;
/**
* Optional node-builder stored between `render()` and `append()` calls.
* When set, the swimlane header renders a DOM node instead of plain text
* (e.g. a user vignette for the "owner" group-by dimension).
* @type {((key: string) => HTMLElement) | undefined}
*/
this._headerNodeFn = undefined;
this._ownerVisible = this._cfg.showOwner;
this._initDelegation();
}
// ── Public API ──────────────────────────────────────────────────────────
/**
* Replace the current item list. Preserves an existing `.list-header`
* at the start of the container.
*
* Items are rendered **in the order supplied** — do not pre-sort or
* split them into folders/files; the caller (or server) owns ordering.
* Folders vs. files are distinguished at render time by whether the item
* has a `mime_type` property.
*
* @param {Array<FileItem|FolderItem>} items
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
* When provided, a swimlane divider is injected whenever the returned
* key changes. Return `null` to suppress the divider for that item.
* @param {((key: string) => string)=} groupLabelFn
* Optional: converts the raw grouping key to a human-readable header
* label. When omitted the key itself is used.
* @param {((key: string) => HTMLElement)=} headerNodeFn
* Optional: builds a rich DOM node for the swimlane header (e.g. a user
* vignette for the "owner" group-by). When provided, `groupLabelFn` is
* ignored for the header and the returned node is appended instead.
*/
render(items, groupFn, groupLabelFn, headerNodeFn) {
const header = this._container.querySelector('.list-header');
this._container.innerHTML = '';
if (header) this._container.appendChild(header);
this._selected.clear();
this._items.clear();
this._lastClickedIndex = -1;
// Reset group tracking for the fresh render
this._lastGroupKey = undefined;
this._lastGroupEl = null;
this._lanes.clear();
this._groupFn = groupFn;
this._groupLabelFn = groupLabelFn;
this._headerNodeFn = headerNodeFn;
// Prevent ui.js global delegation from firing on this container
this._container.dataset.managedBy = 'resource-list';
this._appendItems(items, groupFn, groupLabelFn, headerNodeFn);
this._wireSelectAll();
}
/**
* Append additional items without clearing the existing ones (load-more).
* Continues swimlane grouping from the last item of the previous page —
* no redundant header is inserted when the key is unchanged.
*
* @param {Array<FileItem|FolderItem>} items
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
* @param {((key: string) => string)=} groupLabelFn
* @param {((key: string) => HTMLElement)=} headerNodeFn
*/
append(items, groupFn, groupLabelFn, headerNodeFn) {
// Persist the latest non-undefined callbacks so `addItem()` can
// reuse them without the caller having to re-supply them on every
// optimistic insertion.
if (groupFn !== undefined) this._groupFn = groupFn;
if (groupLabelFn !== undefined) this._groupLabelFn = groupLabelFn;
if (headerNodeFn !== undefined) this._headerNodeFn = headerNodeFn;
this._appendItems(items, groupFn ?? this._groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn);
}
/** Remove all items (but keep `.list-header` if present). */
clear() {
const header = this._container.querySelector('.list-header');
this._container.innerHTML = '';
if (header) this._container.appendChild(header);
this._selected.clear();
this._items.clear();
this._lastClickedIndex = -1;
this._lastGroupKey = undefined;
this._lastGroupEl = null;
this._lanes.clear();
this._groupFn = undefined;
this._groupLabelFn = undefined;
this._headerNodeFn = undefined;
// Hand delegation back to ui.js
delete this._container.dataset.managedBy;
}
/**
* Deselect all items without removing them from the DOM.
* Used by the batch toolbar after an operation completes.
*/
clearSelection() {
this._selected.clear();
this._lastClickedIndex = -1;
this._container.querySelectorAll('.file-item.selected').forEach((card) => {
card.classList.remove('selected');
const cb = /** @type {HTMLInputElement | null} */ (card.querySelector('.item-checkbox'));
if (cb) cb.checked = false;
});
this._syncSelectAllCheckbox();
this._cfg.onSelectionChange?.([]);
}
/**
* Select all visible items in the container.
*/
selectAll() {
this._container.querySelectorAll('.file-item').forEach((card) => {
const el = /** @type {HTMLElement} */ (card);
const id = el.dataset.fileId || el.dataset.folderId || '';
if (!id) return;
el.classList.add('selected');
const cb = /** @type {HTMLInputElement | null} */ (el.querySelector('.item-checkbox'));
if (cb) cb.checked = true;
this._selected.add(id);
});
this._syncSelectAllCheckbox();
this._notifySelectionChange();
}
/**
* Switch between grid and list rendering mode.
* @param {'grid'|'list'} mode
*/
setViewMode(mode) {
this._container.classList.toggle('files-grid-view', mode === 'grid');
this._container.classList.toggle('files-list-view', mode === 'list');
}
/**
* Return the registered item for the given id, or `undefined` if absent.
* @param {string} id
* @returns {FileItem|FolderItem|undefined}
*/
getItem(id) {
return this._items.get(id);
}
/**
* Append a single item, skipping silently if already present (duplicate guard).
* Clears the empty-state placeholder when the first item is added.
*
* Group-by aware: if the current view is grouped, the row goes into
* a dedicated **"New" swimlane pinned at the top of the list** that
* is created on first call and reused across subsequent inserts in
* the same session. This deliberately sidesteps re-computing the
* item's natural bucket on the client:
*
* - Different group-by dimensions (date, type, size, …) would each
* need their own resolver, and date-bucket math is sensitive to
* clock skew between client and server.
* - Cross-swimlane sort-position is impossible to mirror exactly
* without re-implementing the server's tiebreaker chain.
*
* The "New" lane is purely client-side and dissolves on the next
* full reload (when the server's authoritative grouping reasserts).
* Predictable and uniform across every group-by mode.
*
* @param {FileItem|FolderItem} item
* @param {{ scroll?: boolean, highlight?: boolean }} [opts]
* - `scroll`: smooth-scroll the new row into view. The
* `.resource-row--just-added` class also sets `scroll-margin`
* so the sticky page header doesn't cover the row.
* - `highlight`: flash a brief CSS pulse on the new row so the
* user can spot it amid similar siblings.
* @returns {HTMLElement | null} The inserted row, or `null` when the
* item was deduped.
*/
addItem(item, opts = {}) {
if (this._items.has(item.id)) return null;
// Also guard against stale DOM remnants not tracked in _items
const isFile = 'mime_type' in item;
const attr = isFile ? `data-file-id="${item.id}"` : `data-folder-id="${item.id}"`;
if (this._container.querySelector(`.file-item[${attr}]`)) return null;
this._container.classList.remove('hidden');
/** @type {HTMLElement | null} */
let row = null;
if (this._groupFn) {
// Grouped view → drop the new row into the top-of-list
// "New" swimlane, creating it on first call.
const lane = this._ensureJustAddedLane();
this._items.set(item.id, item);
const labels = this._buildItemLabels();
row = isFile ? this._createFileItem(/** @type {FileItem} */ (item), labels) : this._createFolderItem(/** @type {FolderItem} */ (item), labels);
lane.appendChild(row);
} else {
// Flat list (no grouping) — append at the end like before.
this._appendItems([item]);
row = /** @type {HTMLElement | null} */ (this._container.querySelector(`.file-item[${attr}]`));
}
if (!row) return null;
if (opts.highlight) {
row.classList.add('resource-row--just-added');
// Self-cleaning: drop the class once the keyframe completes
// so a future re-render starts from a neutral baseline.
row.addEventListener('animationend', () => row?.classList.remove('resource-row--just-added'), { once: true });
}
if (opts.scroll) {
// `block: 'nearest'` is a no-op when the row is already in
// view. `.resource-row--just-added` sets `scroll-margin-top`
// so the sticky page header doesn't clip the row when the
// scroll lands.
row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
return row;
}
/**
* Ensure the top-of-list "New" swimlane exists and return its
* wrapper. Created on first call after a render; reused across
* subsequent `addItem()` calls in the same session. The lane
* dissolves on the next `render()` / `clear()`, at which point
* the server's authoritative grouping reasserts.
*
* Header label uses i18n key `groupby.justAdded` with an English
* fallback so views that haven't translated it still read sensibly.
*
* @returns {HTMLElement}
*/
_ensureJustAddedLane() {
const existing = this._findLaneByKey(JUST_ADDED_KEY);
if (existing) return existing;
const lane = document.createElement('div');
lane.className = 'resource-list__swimlane-group resource-list__swimlane-group--just-added';
lane.dataset.groupKey = JUST_ADDED_KEY;
this._lanes.set(JUST_ADDED_KEY, lane);
const header = document.createElement('div');
header.className = 'resource-list__swimlane-header';
header.dataset.swimlaneHeader = 'true';
header.textContent = i18n.t('groupby.justAdded', 'New');
lane.appendChild(header);
// Insert at the very top of the container, immediately after
// the optional `.list-header` row, so the affordance is
// discoverable and the row's scroll-into-view brings the
// swimlane header into view too.
const listHeader = this._container.querySelector('.list-header');
if (listHeader?.nextSibling) {
this._container.insertBefore(lane, listHeader.nextSibling);
} else if (listHeader) {
this._container.appendChild(lane);
} else {
this._container.prepend(lane);
}
return lane;
}
/**
* Locate an on-screen swimlane wrapper by its group key. Returns
* `null` when no swimlane currently matches.
*
* O(1) via the `_lanes` registry — see its declaration for how it is
* kept in sync with the DOM.
*
* @param {string} key
* @returns {HTMLElement | null}
*/
_findLaneByKey(key) {
return this._lanes.get(String(key)) ?? null;
}
/**
* Asynchronously fill every un-resolved `.owner-cell` in this component's
* container with the display name for its `data-owner-id` attribute.
* Idempotent — cells already stamped with `data-owner-resolved` are skipped.
* @returns {Promise<void>}
*/
async resolveOwnerCells() {
const cells = /** @type {NodeListOf<HTMLElement>} */ (this._container.querySelectorAll('.owner-cell[data-owner-id]:not([data-owner-resolved])'));
if (!cells.length) return;
systemUsers.prefetch(); // warm cache once (idempotent)
for (const cell of cells) {
const id = cell.dataset.ownerId;
cell.dataset.ownerResolved = '1';
if (!id) continue;
cell.replaceChildren(createUserVignette(id, 'list'));
}
}
/**
* Show or hide the owner column on all current and future items.
* @param {boolean} visible
*/
setOwnerVisible(visible) {
this._ownerVisible = visible;
this._container.querySelectorAll('.owner-cell').forEach((cell) => {
cell.classList.toggle('hidden', !visible);
});
}
/**
* Update the favorite-star visual on a specific item without re-rendering.
* @param {string} id
* @param {'file'|'folder'} type
* @param {boolean} isFavorite
*/
setFavoriteVisualState(id, type, isFavorite) {
const selector = type === 'folder' ? `.file-item[data-folder-id="${id}"]` : `.file-item[data-file-id="${id}"]`;
const item = this._container.querySelector(selector);
if (!item) return;
const star = item.querySelector('.favorite-star');
if (star) {
star.classList.toggle('active', isFavorite);
const i = star.querySelector('i');
if (i) {
i.classList.toggle('fas', isFavorite);
i.classList.toggle('far', !isFavorite);
}
}
const badge = item.querySelector('.file-badge-favorite');
badge?.classList.toggle('hidden', !isFavorite);
}
/**
* Update the shared-badge visual on a specific item without re-rendering.
* @param {string} id
* @param {'file'|'folder'} type
* @param {boolean} isShared
*/
setSharedVisualState(id, type, isShared) {
const selector = type === 'folder' ? `.file-item[data-folder-id="${id}"]` : `.file-item[data-file-id="${id}"]`;
const item = this._container.querySelector(selector);
if (!item) return;
item.querySelector('.file-badge-shared')?.classList.toggle('hidden', !isShared);
}
/**
* Re-evaluate the shared badge for every currently rendered item using the
* `isShared` callback from config. Call this after the grants cache is refreshed.
*/
refreshSharedBadges() {
if (!this._cfg.isShared) return;
for (const item of this._items.values()) {
const isFile = 'mime_type' in item;
const type = /** @type {'file'|'folder'} */ (isFile ? 'file' : 'folder');
this.setSharedVisualState(item.id, type, this._cfg.isShared(item.id, type));
}
}
// ── Private helpers ─────────────────────────────────────────────────────
/**
* Internal: append items to the container in the order supplied.
* Files vs. folders are distinguished by presence of `mime_type`.
*
* @param {Array<FileItem|FolderItem>} items
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
* @param {((key: string) => string)=} groupLabelFn
* @param {((key: string) => HTMLElement)=} headerNodeFn
*/
_appendItems(items, groupFn, groupLabelFn, headerNodeFn) {
const fragment = document.createDocumentFragment();
// Resolve batch-invariant labels once, not once per row.
const labels = this._buildItemLabels();
// Start from the persisted key so load-more pages continue seamlessly.
let lastGroupKey = this._lastGroupKey;
// liveGroup: existing DOM group element from the previous page; items
// that continue its group are appended directly into it (not via fragment).
// fragmentGroup: the group wrapper currently being built in the fragment.
let liveGroup = groupFn ? this._lastGroupEl : null;
let fragmentGroup = /** @type {HTMLElement|null} */ (null);
for (const item of items) {
this._items.set(item.id, item);
if (groupFn) {
const key = groupFn(item);
if (key !== lastGroupKey) {
lastGroupKey = key;
liveGroup = null; // stop extending the previous page's group
fragmentGroup = null;
if (key !== null) {
fragmentGroup = document.createElement('div');
fragmentGroup.className = 'resource-list__swimlane-group';
// Stamp the group key on the wrapper (handy in
// devtools) and register it in `_lanes` so
// `_findLaneByKey()` can locate this swimlane later
// without a container-wide query.
fragmentGroup.dataset.groupKey = key;
this._lanes.set(key, fragmentGroup);
fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn, headerNodeFn));
fragment.appendChild(fragmentGroup);
}
}
}
// Dispatch to the correct renderer: files have mime_type, folders do not.
const isFile = 'mime_type' in item;
const itemEl = isFile
? this._createFileItem(/** @type {FileItem} */ (item), labels)
: this._createFolderItem(/** @type {FolderItem} */ (item), labels);
// Priority: live DOM group (load-more continuation) > current fragment group > bare container
const target = liveGroup ?? fragmentGroup;
if (target) {
target.appendChild(itemEl);
} else {
fragment.appendChild(itemEl);
}
}
this._container.appendChild(fragment);
// Persist for the next append() call (load-more continuity).
this._lastGroupKey = lastGroupKey;
// Track the last group element (live or freshly added) for the next page.
this._lastGroupEl = fragmentGroup ?? liveGroup;
}
/**
* Create a swimlane divider element.
*
* When `headerNodeFn` is supplied the header renders a rich DOM node
* (e.g. a user vignette) instead of plain text; the `--node` CSS modifier
* is added to suppress the small-caps / uppercase text styles.
*
* @param {string} key - Raw grouping key (e.g. UUID or bucket name).
* @param {((key: string) => string)=} labelFn - Optional plain-text resolver.
* @param {((key: string) => HTMLElement)=} headerNodeFn - Optional rich-node builder.
*/
_createGroupHeader(key, labelFn, headerNodeFn) {
const el = document.createElement('div');
el.className = 'resource-list__swimlane-header';
el.dataset.swimlaneHeader = 'true';
if (headerNodeFn) {
el.classList.add('resource-list__swimlane-header--node');
el.appendChild(headerNodeFn(key));
} else {
el.textContent = labelFn ? labelFn(key) : key;
}
return el;
}
/**
* @typedef {Object} ItemLabels
* @property {string} folderTypeLabel - Type-cell label for folders.
* @property {string} customActionsHtml - Pre-rendered inline-action buttons.
* @property {(category: string) => string} fileTypeLabel - Type-cell label
* for a file category (memoized per batch).
*/
/**
* Resolve every per-row value that does not depend on the item once per
* batch: the i18n lookups for the type cell and the custom-actions HTML
* are identical for all 50 rows of a page, so repeating them in
* `_createFileItem` / `_createFolderItem` was pure overhead. Built fresh
* on every call (never cached on the instance), so a locale switch is
* picked up naturally by the next render/append.
*
* @returns {ItemLabels}
*/
_buildItemLabels() {
const fallbackTypeLabel = i18n.t('files.file_types.document');
/** @type {Map<string, string>} */
const byCategory = new Map();
return {
folderTypeLabel: i18n.t('files.file_types.folder'),
customActionsHtml: this._renderCustomActions(),
fileTypeLabel(category) {
if (!category) return fallbackTypeLabel;
let label = byCategory.get(category);
if (label === undefined) {
label = i18n.t(`files.file_types.${category.toLowerCase()}`) || category;
byCategory.set(category, label);
}
return label;
}
};
}
/**
* Build a .file-item DOM element for a folder.
* @param {FolderItem} folder
* @param {ItemLabels} labels - Batch-invariant labels from `_buildItemLabels()`.
* @returns {HTMLElement}
*/
_createFolderItem(folder, labels) {
const cfg = this._cfg;
const el = document.createElement('div');
const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : '';
el.className = `file-item${modClass}`;
el.dataset.folderId = folder.id;
el.dataset.folderName = folder.name;
el.dataset.parentId = folder.parent_id || '';
if (folder.path) el.dataset.path = folder.path;
if (folder.owner_id) el.dataset.ownerId = folder.owner_id;
if (cfg.draggable) el.setAttribute('draggable', 'true');
const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false;
const isShared = cfg.isShared ? cfg.isShared(folder.id, 'folder') : false;
const dateVal = /** @type {Record<string,string>} */ (/** @type {unknown} */ (folder))[cfg.dateField] ?? folder.modified_at;
const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(new Date(dateVal));
el.innerHTML = `
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
<div class="name-cell">
<div class="resource-icon-slot"></div>
<span>${escapeHtml(folder.name)}</span>
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(folder.owner_id || '')}"></div>
${cfg.showPath ? `<div class="path-cell" title="${escapeHtml(folder.path || '')}">${escapeHtml(folder.path || '')}</div>` : ''}
${cfg.showType ? `<div class="type-cell">${labels.folderTypeLabel}</div>` : ''}
<div class="size-cell">--</div>
<div class="date-cell">${formattedDate}</div>
<div class="action-cell">
${labels.customActionsHtml}
${cfg.showFavorite ? `<button class="favorite-star${isFav ? ' active' : ''}"><i class="${isFav ? 'fas' : 'far'} fa-star"></i></button>` : ''}
${cfg.showContextMenu ? '<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>' : ''}
</div>
`;
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder'));
return el;
}
/**
* Build a .file-item DOM element for a file.
* @param {FileItem} file
* @param {ItemLabels} labels - Batch-invariant labels from `_buildItemLabels()`.
* @returns {HTMLElement}
*/
_createFileItem(file, labels) {
const cfg = this._cfg;
const typeLabel = labels.fileTypeLabel(file.category || '');
const fileSize = file.size_formatted || formatFileSize(file.size);
const dateVal = /** @type {Record<string,string>} */ (/** @type {unknown} */ (file))[cfg.dateField] ?? file.modified_at;
const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(new Date(dateVal));
const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false;
const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false;
const el = document.createElement('div');
const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : '';
el.className = `file-item${modClass}`;
el.dataset.fileId = file.id;
el.dataset.fileName = file.name;
el.dataset.folderId = file.folder_id || '';
if (file.path) el.dataset.path = file.path;
if (file.owner_id) el.dataset.ownerId = file.owner_id;
if (cfg.draggable) el.setAttribute('draggable', 'true');
el.innerHTML = `
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
<div class="name-cell">
<div class="resource-icon-slot"></div>
<span>${escapeHtml(file.name)}</span>
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
${file.snippet ? `<span class="file-item__snippet" title="${escapeHtml(file.snippet)}">${escapeHtml(file.snippet)}</span>` : ''}
</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
${cfg.showPath ? `<div class="path-cell" title="${escapeHtml(file.path || '')}">${escapeHtml(file.path || '')}</div>` : ''}
${cfg.showType ? `<div class="type-cell">${typeLabel}</div>` : ''}
<div class="size-cell">${fileSize}</div>
<div class="date-cell">${formattedDate}</div>
<div class="action-cell">
${labels.customActionsHtml}
${cfg.showFavorite ? `<button class="favorite-star${isFav ? ' active' : ''}"><i class="${isFav ? 'fas' : 'far'} fa-star"></i></button>` : ''}
${cfg.showContextMenu ? '<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>' : ''}
</div>
`;
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(file, 'file'));
return el;
}
/**
* Render the inline action buttons declared in `cfg.customActions`.
* Each button gets `data-custom-action="<index>"` so the binder can
* dispatch by position. Returns an empty string when no actions are
* configured.
* @returns {string}
*/
_renderCustomActions() {
const actions = this._cfg.customActions;
if (!actions?.length) return '';
return actions
.map((a, i) => {
const cls = a.className ? ` ${a.className}` : '';
const label = a.labelKey ? escapeHtml(i18n.t(a.labelKey)) : '';
return `<button type="button" class="btn-action${cls}" data-custom-action="${i}" title="${label}" aria-label="${label}">${a.iconHtml}</button>`;
})
.join('');
}
/** Wire one delegated listener for all pointer events in this container. */
_initDelegation() {
const container = this._container;
const cfg = this._cfg;
// ── click ──────────────────────────────────────────────────────────
container.addEventListener('click', (e) => {
const target = /** @type {HTMLElement} */ (e.target);
// Swimlane dividers are not interactive
if (target.dataset.swimlaneHeader) return;
const card = /** @type {HTMLElement | null} */ (target.closest('.file-item'));
if (!card) return;
// Three-dots button → context menu
if (target.closest('.file-actions')) {
e.stopPropagation();
e.preventDefault();
const item = this._itemFromCard(card);
if (item && cfg.onContextMenu) cfg.onContextMenu(item, /** @type {MouseEvent} */ (e));
return;
}
// Favorite-star button — never opens the card
if (target.closest('.favorite-star')) {
e.preventDefault();
const item = this._itemFromCard(card);
if (item) cfg.onFavoriteToggle?.(item);
return;
}
// Custom inline actions (e.g. restore / delete-permanently on trash)
const customBtn = /** @type {HTMLElement | null} */ (target.closest('button[data-custom-action]'));
if (customBtn) {
e.preventDefault();
const idx = Number(customBtn.dataset.customAction);
const action = cfg.customActions?.[idx];
const item = this._itemFromCard(card);
if (action && item) action.onClick(item);
return;
}
// Shared-badge click → open share modal (or fall back to context menu)
if (target.closest('.file-badge-shared')) {
e.preventDefault();
const item = this._itemFromCard(card);
if (item) {
if (cfg.onShareBadgeClick) {
cfg.onShareBadgeClick(item);
} else {
cfg.onContextMenu?.(item, /** @type {MouseEvent} */ (e));
}
}
return;
}
// Checkbox cell → selection (shift extends range)
if (cfg.selectable && target.closest('.checkbox-cell')) {
if (e.shiftKey) {
this._handleShiftSelect(card);
} else {
this._toggleSelection(card);
}
return;
}
// Modifier-key click → selection toggle
if (e.metaKey || e.altKey || e.ctrlKey) {
if (cfg.selectable) this._toggleSelection(card);
return;
}
// Shift-click anywhere on the card → extend selection range
if (e.shiftKey && cfg.selectable) {
this._handleShiftSelect(card);
return;
}
// Plain click → open or navigate
const item = this._itemFromCard(card);
if (item && cfg.onOpen) cfg.onOpen(item, /** @type {MouseEvent} */ (e));
});
// ── contextmenu ────────────────────────────────────────────────────
if (cfg.showContextMenu) {
container.addEventListener('contextmenu', (e) => {
const target = /** @type {HTMLElement} */ (e.target);
if (target.dataset.swimlaneHeader) return;
const card = /** @type {HTMLElement | null} */ (target.closest('.file-item'));
if (!card) return;
e.preventDefault();
const item = this._itemFromCard(card);
if (item && cfg.onContextMenu) cfg.onContextMenu(item, /** @type {MouseEvent} */ (e));
});
}
// ── dblclick — prevent double-fire of open on rapid clicks ─────────
container.addEventListener('dblclick', (e) => e.preventDefault());
}
/**
* Return the registered item object for a given card element.
* @param {HTMLElement} card
* @returns {FileItem|FolderItem|undefined}
*/
_itemFromCard(card) {
const id = card.dataset.fileId || card.dataset.folderId || '';
return this._items.get(id);
}
/**
* Toggle selection on a single card and notify.
* Tracks `_lastClickedIndex` for subsequent shift-clicks.
* @param {HTMLElement} card
*/
_toggleSelection(card) {
const id = card.dataset.fileId || card.dataset.folderId || '';
if (!id) return;
const nowSelected = !card.classList.contains('selected');
card.classList.toggle('selected', nowSelected);
const checkbox = /** @type {HTMLInputElement | null} */ (card.querySelector('.item-checkbox'));
if (checkbox) checkbox.checked = nowSelected;
if (nowSelected) {
this._selected.add(id);
} else {
this._selected.delete(id);
}
// Record position for the next shift-click
const items = [...this._container.querySelectorAll('.file-item')];
this._lastClickedIndex = items.indexOf(card);
this._syncSelectAllCheckbox();
this._notifySelectionChange();
}
/**
* Extend the selection from `_lastClickedIndex` to `card` (inclusive).
* If no previous click exists, falls back to a plain toggle.
* @param {HTMLElement} card
*/
_handleShiftSelect(card) {
const items = /** @type {HTMLElement[]} */ ([...this._container.querySelectorAll('.file-item')]);
const index = items.indexOf(card);
if (this._lastClickedIndex >= 0 && index >= 0) {
const start = Math.min(this._lastClickedIndex, index);
const end = Math.max(this._lastClickedIndex, index);
for (let i = start; i <= end; i++) {
const el = items[i];
const id = el.dataset.fileId || el.dataset.folderId || '';
if (!id) continue;
el.classList.add('selected');
const cb = /** @type {HTMLInputElement | null} */ (el.querySelector('.item-checkbox'));
if (cb) cb.checked = true;
this._selected.add(id);
}
} else {
this._toggleSelection(card);
return;
}
this._lastClickedIndex = index;
this._syncSelectAllCheckbox();
this._notifySelectionChange();
}
/**
* Find the select-all checkbox in the container header and wire its
* `change` event. Called after every `render()`.
*/
_wireSelectAll() {
if (!this._cfg.selectable) return;
const cb = /** @type {HTMLInputElement | null} */ (this._container.querySelector('#select-all-checkbox'));
if (!cb) return;
// Replace with a fresh listener to avoid duplicates across re-renders
const fresh = /** @type {HTMLInputElement} */ (cb.cloneNode(true));
cb.parentNode?.replaceChild(fresh, cb);
fresh.addEventListener('change', () => {
if (fresh.checked) {
this.selectAll();
} else {
this.clearSelection();
}
});
}
/**
* Sync the three-state select-all checkbox in the list header.
* Checked = all selected, indeterminate = some selected, unchecked = none.
*/
_syncSelectAllCheckbox() {
const cb = /** @type {HTMLInputElement | null} */ (this._container.querySelector('#select-all-checkbox'));
if (!cb) return;
const total = this._container.querySelectorAll('.file-item').length;
if (total === 0) {
cb.checked = false;
cb.indeterminate = false;
} else if (this._selected.size >= total) {
cb.checked = true;
cb.indeterminate = false;
} else if (this._selected.size > 0) {
cb.checked = false;
cb.indeterminate = true;
} else {
cb.checked = false;
cb.indeterminate = false;
}
}
/** Build the selected-items array and fire `onSelectionChange`. */
_notifySelectionChange() {
if (!this._cfg.onSelectionChange) return;
/** @type {Array<FileItem|FolderItem>} */
const selectedItems = [...this._selected].flatMap((id) => {
const item = this._items.get(id);
return item ? [item] : [];
});
this._cfg.onSelectionChange(selectedItems);
}
}