feat(ui:items): uploading an item with a swimlane display

restore the legacy display with new element uploaded, when swimlane is in place
    as the sort is done by server side just add new element in a "new elements" swimlane.
    if user continue to scroll down in cursor/pages and item is found from server,
    UI remove it from the new element and restore the position

    other option: is user refresh it's page, server will restore the natural order
This commit is contained in:
Edouard Vanbelle
2026-07-20 22:03:08 +02:00
parent ae6e3a8eb3
commit a520afcf7c
18 changed files with 172 additions and 44 deletions
@@ -1239,6 +1239,7 @@
<div class="files-list-view" style="--files-list-columns: {columns}">
{#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if}
{#each sections as section (section.key)}
{#if section.label}
<div class="rl-swimlane-header" role="rowheader">
<span class="rl-swimlane-header__label">{section.label}</span>
{#if bucketAction}
@@ -1247,6 +1248,7 @@
</span>
{/if}
</div>
{/if}
<!-- Window each section's rows so a large grouped list (e.g. a big
trash, grouped by remaining days) doesn't mount every row. -->
<VirtualList items={section.rows} rowHeight={56} key={(e) => e.id} {row} />
@@ -1262,6 +1264,7 @@
(benches/ROUND13.md §V1). -->
<div class="rl-grouped-grid">
{#each sections as section (section.key)}
{#if section.label}
<div class="rl-swimlane-header rl-swimlane-header--grid" role="rowheader">
<span class="rl-swimlane-header__label">{section.label}</span>
{#if bucketAction}
@@ -1270,6 +1273,7 @@
</span>
{/if}
</div>
{/if}
<VirtualList
items={section.rows}
columns={gridCols}
+122 -14
View File
@@ -121,6 +121,26 @@
// enqueue two concurrent next-page fetches on the same cursor.
let loadingMore = $state(false);
// ── Transient "New elements" swimlane ────────────────────────────
// Server sort is authoritative, so an uploaded file lands wherever
// its sort key places it — possibly halfway down the list or on an
// unloaded page. To confirm "your upload worked", we detect items
// that appeared on the current page after a mutation (upload / new
// folder / dropped tree) and hoist them into a first-class swimlane
// at the top of the list. The swimlane clears on next folder nav.
//
// Detection strategy: caller of `reloadAndTrackNew()` snapshots the
// current id set, runs `reload()`, and diffs the freshly-loaded
// page 1 against the snapshot — every new id joins `newlyAdded`.
//
// Known limitation: server-sort places the new item on an unloaded
// page (e.g. sort=size on a 5 000-item folder and the new file is
// mid-size). It won't appear in the swimlane until the user scrolls
// far enough for that page to load. Threading the returned FileItem
// out of the 5-layer upload stack would fix this — deferred until
// the diff approach proves insufficient.
const newlyAdded = new SvelteSet<string>();
// Dotfile hide filter is now applied inside `rlItems` (below) directly
// on the server-ordered accumulator, so a single filter pass feeds
// ResourceList. Selection / batch ops iterate ResourceList's own
@@ -371,6 +391,36 @@
await load(true);
}
/**
* Reload + populate the "new elements" swimlane with anything that
* appeared on page 1 after the mutation.
*
* Called from mutation paths that ADD items (upload / dropped tree /
* create-folder). Renames, deletes, moves use plain `reload()`
* — nothing new to hoist.
*/
async function reloadAndTrackNew(): Promise<void> {
const before = new SvelteSet<string>();
for (const it of orderedItems) before.add(it.id);
await reload();
// `reload()` resets `pageCursor` + fetches page 1 fresh, so
// `orderedItems` is now the freshly-loaded page. Every id that
// wasn't there before this reload joins the swimlane.
newlyAdded.clear();
for (const it of orderedItems) if (!before.has(it.id)) newlyAdded.add(it.id);
// Scroll the page back to the top so the freshly-hoisted "New
// elements" swimlane is visible without the user having to hunt
// for it — the whole point of the swimlane is to confirm "your
// upload landed". Only fires when we actually detected new items,
// so a bare reload doesn't yank the user's scroll position.
// Smooth scroll for the visual continuity — instant would feel
// like the page reloaded. `scrollTo` at (0, 0) is a no-op if
// the user was already at the top; no jitter cost.
if (newlyAdded.size > 0 && typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
function openFolder(folder: FolderItem) {
goto(resolve(`/files/${[...pathSegments, folder.id].join('/')}`));
}
@@ -384,7 +434,7 @@
if (!name) return;
try {
await createFolder(name, currentId);
await reload();
await reloadAndTrackNew();
// Vanish-warning: user just made a `.folder` and it's
// already hidden by their preference — otherwise the new
// folder would appear to have not been created. Third hook
@@ -668,7 +718,7 @@
} else {
finishUpload(nid, 0, 0, 0, skipped.length);
}
await reload();
await reloadAndTrackNew();
// Storage usage changed server-side — pull the fresh figure so the
// "Almacenamiento" bar moves off its login value instead of 0%.
void session.refresh();
@@ -1375,7 +1425,7 @@
const { savedBytes, failures } = await uploadAll(items, nid, label);
finishUpload(nid, savedBytes, failures, total, skipped.length);
await reload();
await reloadAndTrackNew();
void session.refresh();
} catch (err) {
ui.finishProgress(nid, errorMessage(err), 'error');
@@ -1418,7 +1468,25 @@
// necessary. Under order_by=name/type/size the server puts folders
// first then files; under modified_at/created_at they interleave —
// preserving the accumulator order is what surfaces that correctly.
const rlItems = $derived(filterDotfiles(orderedItems, preferences.hideDotfiles));
//
// Hoist step: items in `newlyAdded` (populated by `reloadAndTrackNew`
// after an upload / create / dropped tree) are pulled OUT of their
// natural-order position and PREPENDED to the list, so the
// "__new__" bucket rendered by the composed groupBy below appears
// at the top of the swimlanes regardless of what sort/group the
// user has active. First-appearance bucketing in
// `buildResourceSections` keys off the item order in the input list.
const rlItems = $derived.by<Array<FileItem | FolderItem>>(() => {
const filtered = filterDotfiles(orderedItems, preferences.hideDotfiles);
if (newlyAdded.size === 0) return filtered;
const hoisted: Array<FileItem | FolderItem> = [];
const rest: Array<FileItem | FolderItem> = [];
for (const it of filtered) {
if (newlyAdded.has(it.id)) hoisted.push(it);
else rest.push(it);
}
return [...hoisted, ...rest];
});
// Group-by state (bound to <ResourceList>). Kept as a `string` prop
// value; the current `sortField` mirrors from the picked group's
@@ -1429,40 +1497,75 @@
// modifiedAt / createdAt). The `orderBy` values are what the
// GROUP_BYS toolbar emits, so <ResourceList>'s onreload gets the
// legacy `sortField` name and can drive the same sort path.
//
// Every dimension composes a `__new__` branch on top of its natural
// `bucketOf` so that whenever the transient "new elements" swimlane
// is active, hoisted items get their own bucket-first-in-order
// regardless of the user's chosen group. On the default `''` (flat)
// dimension the wrapped `bucketOf` returns the empty string for
// non-new items — that renders as one unlabeled section (header
// suppressed by ResourceList when `label === ''`), preserving the
// current flat-list look with just the "New elements" header on
// top. `labelForNew` renders the localised header.
const NEW_KEY = '__new__';
const labelForNew = $derived(t('files.new_elements', 'New elements'));
const wrapNew =
<T extends FileItem | FolderItem>(inner?: (item: T) => string | null) =>
(item: T): string | null => {
if (newlyAdded.has(item.id)) return NEW_KEY;
return inner ? inner(item) : '';
};
const wrapLabel =
(inner?: (key: string) => string) =>
(key: string): string => {
if (key === NEW_KEY) return labelForNew;
return inner ? inner(key) : key;
};
const rlGroupBys = $derived<RLGroupByDef[]>([
{ key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
{
key: '',
label: t('files.name', 'Name'),
orderBy: 'name',
icon: 'arrow-up-a-z',
// Only synthesize a bucketOf when the swimlane is active; when
// no new items exist we want the plain flat-list rendering
// (no bucketing pass at all).
bucketOf: newlyAdded.size > 0 ? wrapNew() : undefined,
labelOf: newlyAdded.size > 0 ? wrapLabel() : undefined
},
{
key: 'type',
label: t('groupby.type', 'Type'),
orderBy: 'type',
icon: 'layer-group',
bucketOf: (item) =>
isFile(item) ? typeLabel(item.category) : t('files.file_types.folder', 'Folders'),
labelOf: (k) => k
bucketOf: wrapNew((item) =>
isFile(item) ? typeLabel(item.category) : t('files.file_types.folder', 'Folders')
),
labelOf: wrapLabel((k) => k)
},
{
key: 'size',
label: t('groupby.size', 'Size'),
orderBy: 'size',
icon: 'layer-group',
bucketOf: (item) => (isFile(item) ? sizeBucket(item.size ?? 0) : sizeBucket(-1)),
labelOf: (k) => k
bucketOf: wrapNew((item) => (isFile(item) ? sizeBucket(item.size ?? 0) : sizeBucket(-1))),
labelOf: wrapLabel((k) => k)
},
{
key: 'modifiedAt',
label: t('groupby.modifiedAt', 'Modified date'),
orderBy: 'modified_at',
icon: 'layer-group',
bucketOf: (item) => dateBucket(item.modified_at),
labelOf: (k) => k
bucketOf: wrapNew((item) => dateBucket(item.modified_at)),
labelOf: wrapLabel((k) => k)
},
{
key: 'createdAt',
label: t('groupby.createdAt', 'Created date'),
orderBy: 'created_at',
icon: 'layer-group',
bucketOf: (item) => dateBucket(item.created_at),
labelOf: (k) => k
bucketOf: wrapNew((item) => dateBucket(item.created_at)),
labelOf: wrapLabel((k) => k)
}
]);
@@ -1533,6 +1636,11 @@
void sortField;
void reversed;
untrack(() => {
// Route/sort change → drop the transient "new elements"
// swimlane. It's a per-folder confirmation of "here's what
// you just added"; carrying it across folders would surface
// stale ids that don't belong to the new listing.
newlyAdded.clear();
void load(true);
});
});
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "أضيف",
"col_created_by": "أنشئ بواسطة",
"col_opened": "افتُح",
"col_path": "الموقع"
"col_path": "الموقع",
"new_elements": "عناصر جديدة"
},
"dialogs": {
"rename_folder": "إعادة تسمية المجلد",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Hinzugefügt",
"col_created_by": "Erstellt von",
"col_opened": "Geöffnet",
"col_path": "Speicherort"
"col_path": "Speicherort",
"new_elements": "Neue Elemente"
},
"dialogs": {
"rename_folder": "Ordner umbenennen",
+1
View File
@@ -504,6 +504,7 @@
"moved": "Moved",
"new_folder": "New folder",
"new_folder_prompt": "New folder name",
"new_elements": "New elements",
"no_home": "No home folder available.",
"no_preview": "No preview available for this file type.",
"no_subfolders": "No subfolders here.",
+2 -1
View File
@@ -387,7 +387,8 @@
"col_added": "Añadido",
"col_created_by": "Creado por",
"col_opened": "Abierto",
"col_path": "Ubicación"
"col_path": "Ubicación",
"new_elements": "Nuevos elementos"
},
"dialogs": {
"rename_folder": "Renombrar carpeta",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "افزوده شده",
"col_created_by": "ایجاد شده توسط",
"col_opened": "باز شده",
"col_path": "مکان"
"col_path": "مکان",
"new_elements": "موارد جدید"
},
"dialogs": {
"rename_folder": "تغییر نام پوشه",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Ajouté",
"col_created_by": "Créé par",
"col_opened": "Ouvert",
"col_path": "Emplacement"
"col_path": "Emplacement",
"new_elements": "Nouveaux éléments"
},
"dialogs": {
"rename_folder": "Renommer le dossier",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "जोड़ा गया",
"col_created_by": "द्वारा बनाया गया",
"col_opened": "खोला गया",
"col_path": "स्थान"
"col_path": "स्थान",
"new_elements": "नए तत्व"
},
"dialogs": {
"rename_folder": "फ़ोल्डर का नाम बदलें",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Aggiunto",
"col_created_by": "Creato da",
"col_opened": "Aperto",
"col_path": "Posizione"
"col_path": "Posizione",
"new_elements": "Nuovi elementi"
},
"dialogs": {
"rename_folder": "Rinomina cartella",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "追加日",
"col_created_by": "作成者",
"col_opened": "アクセス日時",
"col_path": "場所"
"col_path": "場所",
"new_elements": "新しいアイテム"
},
"dialogs": {
"rename_folder": "フォルダ名を変更",
+1
View File
@@ -471,6 +471,7 @@
"move_title": "\"{{name}}\" 이동",
"moved": "이동됨",
"new_folder_prompt": "새 폴더 이름",
"new_elements": "새 항목",
"no_home": "홈 폴더를 사용할 수 없습니다.",
"no_preview": "이 파일 형식은 미리보기를 지원하지 않습니다.",
"no_subfolders": "하위 폴더가 없습니다.",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Toegevoegd",
"col_created_by": "Gemaakt door",
"col_opened": "Geopend",
"col_path": "Locatie"
"col_path": "Locatie",
"new_elements": "Nieuwe items"
},
"dialogs": {
"rename_folder": "Map hernoemen",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Dodano",
"col_created_by": "Utworzone przez",
"col_opened": "Otwarte",
"col_path": "Lokalizacja"
"col_path": "Lokalizacja",
"new_elements": "Nowe elementy"
},
"dialogs": {
"rename_folder": "Zmień nazwę folderu",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Adicionado",
"col_created_by": "Criado por",
"col_opened": "Aberto",
"col_path": "Localização"
"col_path": "Localização",
"new_elements": "Novos itens"
},
"dialogs": {
"rename_folder": "Renomear pasta",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Добавлено",
"col_created_by": "Создано",
"col_opened": "Открыт",
"col_path": "Расположение"
"col_path": "Расположение",
"new_elements": "Новые элементы"
},
"dialogs": {
"rename_folder": "Переименовать папку",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "新增日期",
"col_created_by": "建立者",
"col_opened": "開啟日期",
"col_path": "位置"
"col_path": "位置",
"new_elements": "新項目"
},
"dialogs": {
"rename_folder": "重新命名資料夾",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "添加日期",
"col_created_by": "创建者",
"col_opened": "打开日期",
"col_path": "位置"
"col_path": "位置",
"new_elements": "新元素"
},
"dialogs": {
"rename_folder": "重命名文件夹",