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
+20 -16
View File
@@ -1239,14 +1239,16 @@
<div class="files-list-view" style="--files-list-columns: {columns}"> <div class="files-list-view" style="--files-list-columns: {columns}">
{#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if}
{#each sections as section (section.key)} {#each sections as section (section.key)}
<div class="rl-swimlane-header" role="rowheader"> {#if section.label}
<span class="rl-swimlane-header__label">{section.label}</span> <div class="rl-swimlane-header" role="rowheader">
{#if bucketAction} <span class="rl-swimlane-header__label">{section.label}</span>
<span class="rl-swimlane-header__action"> {#if bucketAction}
{@render bucketAction(section.key)} <span class="rl-swimlane-header__action">
</span> {@render bucketAction(section.key)}
{/if} </span>
</div> {/if}
</div>
{/if}
<!-- Window each section's rows so a large grouped list (e.g. a big <!-- Window each section's rows so a large grouped list (e.g. a big
trash, grouped by remaining days) doesn't mount every row. --> trash, grouped by remaining days) doesn't mount every row. -->
<VirtualList items={section.rows} rowHeight={56} key={(e) => e.id} {row} /> <VirtualList items={section.rows} rowHeight={56} key={(e) => e.id} {row} />
@@ -1262,14 +1264,16 @@
(benches/ROUND13.md §V1). --> (benches/ROUND13.md §V1). -->
<div class="rl-grouped-grid"> <div class="rl-grouped-grid">
{#each sections as section (section.key)} {#each sections as section (section.key)}
<div class="rl-swimlane-header rl-swimlane-header--grid" role="rowheader"> {#if section.label}
<span class="rl-swimlane-header__label">{section.label}</span> <div class="rl-swimlane-header rl-swimlane-header--grid" role="rowheader">
{#if bucketAction} <span class="rl-swimlane-header__label">{section.label}</span>
<span class="rl-swimlane-header__action"> {#if bucketAction}
{@render bucketAction(section.key)} <span class="rl-swimlane-header__action">
</span> {@render bucketAction(section.key)}
{/if} </span>
</div> {/if}
</div>
{/if}
<VirtualList <VirtualList
items={section.rows} items={section.rows}
columns={gridCols} columns={gridCols}
+122 -14
View File
@@ -121,6 +121,26 @@
// enqueue two concurrent next-page fetches on the same cursor. // enqueue two concurrent next-page fetches on the same cursor.
let loadingMore = $state(false); 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 // Dotfile hide filter is now applied inside `rlItems` (below) directly
// on the server-ordered accumulator, so a single filter pass feeds // on the server-ordered accumulator, so a single filter pass feeds
// ResourceList. Selection / batch ops iterate ResourceList's own // ResourceList. Selection / batch ops iterate ResourceList's own
@@ -371,6 +391,36 @@
await load(true); 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) { function openFolder(folder: FolderItem) {
goto(resolve(`/files/${[...pathSegments, folder.id].join('/')}`)); goto(resolve(`/files/${[...pathSegments, folder.id].join('/')}`));
} }
@@ -384,7 +434,7 @@
if (!name) return; if (!name) return;
try { try {
await createFolder(name, currentId); await createFolder(name, currentId);
await reload(); await reloadAndTrackNew();
// Vanish-warning: user just made a `.folder` and it's // Vanish-warning: user just made a `.folder` and it's
// already hidden by their preference — otherwise the new // already hidden by their preference — otherwise the new
// folder would appear to have not been created. Third hook // folder would appear to have not been created. Third hook
@@ -668,7 +718,7 @@
} else { } else {
finishUpload(nid, 0, 0, 0, skipped.length); finishUpload(nid, 0, 0, 0, skipped.length);
} }
await reload(); await reloadAndTrackNew();
// Storage usage changed server-side — pull the fresh figure so the // Storage usage changed server-side — pull the fresh figure so the
// "Almacenamiento" bar moves off its login value instead of 0%. // "Almacenamiento" bar moves off its login value instead of 0%.
void session.refresh(); void session.refresh();
@@ -1375,7 +1425,7 @@
const { savedBytes, failures } = await uploadAll(items, nid, label); const { savedBytes, failures } = await uploadAll(items, nid, label);
finishUpload(nid, savedBytes, failures, total, skipped.length); finishUpload(nid, savedBytes, failures, total, skipped.length);
await reload(); await reloadAndTrackNew();
void session.refresh(); void session.refresh();
} catch (err) { } catch (err) {
ui.finishProgress(nid, errorMessage(err), 'error'); ui.finishProgress(nid, errorMessage(err), 'error');
@@ -1418,7 +1468,25 @@
// necessary. Under order_by=name/type/size the server puts folders // necessary. Under order_by=name/type/size the server puts folders
// first then files; under modified_at/created_at they interleave — // first then files; under modified_at/created_at they interleave —
// preserving the accumulator order is what surfaces that correctly. // 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 // Group-by state (bound to <ResourceList>). Kept as a `string` prop
// value; the current `sortField` mirrors from the picked group's // value; the current `sortField` mirrors from the picked group's
@@ -1429,40 +1497,75 @@
// modifiedAt / createdAt). The `orderBy` values are what the // modifiedAt / createdAt). The `orderBy` values are what the
// GROUP_BYS toolbar emits, so <ResourceList>'s onreload gets the // GROUP_BYS toolbar emits, so <ResourceList>'s onreload gets the
// legacy `sortField` name and can drive the same sort path. // 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[]>([ 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', key: 'type',
label: t('groupby.type', 'Type'), label: t('groupby.type', 'Type'),
orderBy: 'type', orderBy: 'type',
icon: 'layer-group', icon: 'layer-group',
bucketOf: (item) => bucketOf: wrapNew((item) =>
isFile(item) ? typeLabel(item.category) : t('files.file_types.folder', 'Folders'), isFile(item) ? typeLabel(item.category) : t('files.file_types.folder', 'Folders')
labelOf: (k) => k ),
labelOf: wrapLabel((k) => k)
}, },
{ {
key: 'size', key: 'size',
label: t('groupby.size', 'Size'), label: t('groupby.size', 'Size'),
orderBy: 'size', orderBy: 'size',
icon: 'layer-group', icon: 'layer-group',
bucketOf: (item) => (isFile(item) ? sizeBucket(item.size ?? 0) : sizeBucket(-1)), bucketOf: wrapNew((item) => (isFile(item) ? sizeBucket(item.size ?? 0) : sizeBucket(-1))),
labelOf: (k) => k labelOf: wrapLabel((k) => k)
}, },
{ {
key: 'modifiedAt', key: 'modifiedAt',
label: t('groupby.modifiedAt', 'Modified date'), label: t('groupby.modifiedAt', 'Modified date'),
orderBy: 'modified_at', orderBy: 'modified_at',
icon: 'layer-group', icon: 'layer-group',
bucketOf: (item) => dateBucket(item.modified_at), bucketOf: wrapNew((item) => dateBucket(item.modified_at)),
labelOf: (k) => k labelOf: wrapLabel((k) => k)
}, },
{ {
key: 'createdAt', key: 'createdAt',
label: t('groupby.createdAt', 'Created date'), label: t('groupby.createdAt', 'Created date'),
orderBy: 'created_at', orderBy: 'created_at',
icon: 'layer-group', icon: 'layer-group',
bucketOf: (item) => dateBucket(item.created_at), bucketOf: wrapNew((item) => dateBucket(item.created_at)),
labelOf: (k) => k labelOf: wrapLabel((k) => k)
} }
]); ]);
@@ -1533,6 +1636,11 @@
void sortField; void sortField;
void reversed; void reversed;
untrack(() => { 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); void load(true);
}); });
}); });
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "أضيف", "col_added": "أضيف",
"col_created_by": "أنشئ بواسطة", "col_created_by": "أنشئ بواسطة",
"col_opened": "افتُح", "col_opened": "افتُح",
"col_path": "الموقع" "col_path": "الموقع",
"new_elements": "عناصر جديدة"
}, },
"dialogs": { "dialogs": {
"rename_folder": "إعادة تسمية المجلد", "rename_folder": "إعادة تسمية المجلد",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Hinzugefügt", "col_added": "Hinzugefügt",
"col_created_by": "Erstellt von", "col_created_by": "Erstellt von",
"col_opened": "Geöffnet", "col_opened": "Geöffnet",
"col_path": "Speicherort" "col_path": "Speicherort",
"new_elements": "Neue Elemente"
}, },
"dialogs": { "dialogs": {
"rename_folder": "Ordner umbenennen", "rename_folder": "Ordner umbenennen",
+1
View File
@@ -504,6 +504,7 @@
"moved": "Moved", "moved": "Moved",
"new_folder": "New folder", "new_folder": "New folder",
"new_folder_prompt": "New folder name", "new_folder_prompt": "New folder name",
"new_elements": "New elements",
"no_home": "No home folder available.", "no_home": "No home folder available.",
"no_preview": "No preview available for this file type.", "no_preview": "No preview available for this file type.",
"no_subfolders": "No subfolders here.", "no_subfolders": "No subfolders here.",
+2 -1
View File
@@ -387,7 +387,8 @@
"col_added": "Añadido", "col_added": "Añadido",
"col_created_by": "Creado por", "col_created_by": "Creado por",
"col_opened": "Abierto", "col_opened": "Abierto",
"col_path": "Ubicación" "col_path": "Ubicación",
"new_elements": "Nuevos elementos"
}, },
"dialogs": { "dialogs": {
"rename_folder": "Renombrar carpeta", "rename_folder": "Renombrar carpeta",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "افزوده شده", "col_added": "افزوده شده",
"col_created_by": "ایجاد شده توسط", "col_created_by": "ایجاد شده توسط",
"col_opened": "باز شده", "col_opened": "باز شده",
"col_path": "مکان" "col_path": "مکان",
"new_elements": "موارد جدید"
}, },
"dialogs": { "dialogs": {
"rename_folder": "تغییر نام پوشه", "rename_folder": "تغییر نام پوشه",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Ajouté", "col_added": "Ajouté",
"col_created_by": "Créé par", "col_created_by": "Créé par",
"col_opened": "Ouvert", "col_opened": "Ouvert",
"col_path": "Emplacement" "col_path": "Emplacement",
"new_elements": "Nouveaux éléments"
}, },
"dialogs": { "dialogs": {
"rename_folder": "Renommer le dossier", "rename_folder": "Renommer le dossier",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "जोड़ा गया", "col_added": "जोड़ा गया",
"col_created_by": "द्वारा बनाया गया", "col_created_by": "द्वारा बनाया गया",
"col_opened": "खोला गया", "col_opened": "खोला गया",
"col_path": "स्थान" "col_path": "स्थान",
"new_elements": "नए तत्व"
}, },
"dialogs": { "dialogs": {
"rename_folder": "फ़ोल्डर का नाम बदलें", "rename_folder": "फ़ोल्डर का नाम बदलें",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Aggiunto", "col_added": "Aggiunto",
"col_created_by": "Creato da", "col_created_by": "Creato da",
"col_opened": "Aperto", "col_opened": "Aperto",
"col_path": "Posizione" "col_path": "Posizione",
"new_elements": "Nuovi elementi"
}, },
"dialogs": { "dialogs": {
"rename_folder": "Rinomina cartella", "rename_folder": "Rinomina cartella",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "追加日", "col_added": "追加日",
"col_created_by": "作成者", "col_created_by": "作成者",
"col_opened": "アクセス日時", "col_opened": "アクセス日時",
"col_path": "場所" "col_path": "場所",
"new_elements": "新しいアイテム"
}, },
"dialogs": { "dialogs": {
"rename_folder": "フォルダ名を変更", "rename_folder": "フォルダ名を変更",
+1
View File
@@ -471,6 +471,7 @@
"move_title": "\"{{name}}\" 이동", "move_title": "\"{{name}}\" 이동",
"moved": "이동됨", "moved": "이동됨",
"new_folder_prompt": "새 폴더 이름", "new_folder_prompt": "새 폴더 이름",
"new_elements": "새 항목",
"no_home": "홈 폴더를 사용할 수 없습니다.", "no_home": "홈 폴더를 사용할 수 없습니다.",
"no_preview": "이 파일 형식은 미리보기를 지원하지 않습니다.", "no_preview": "이 파일 형식은 미리보기를 지원하지 않습니다.",
"no_subfolders": "하위 폴더가 없습니다.", "no_subfolders": "하위 폴더가 없습니다.",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Toegevoegd", "col_added": "Toegevoegd",
"col_created_by": "Gemaakt door", "col_created_by": "Gemaakt door",
"col_opened": "Geopend", "col_opened": "Geopend",
"col_path": "Locatie" "col_path": "Locatie",
"new_elements": "Nieuwe items"
}, },
"dialogs": { "dialogs": {
"rename_folder": "Map hernoemen", "rename_folder": "Map hernoemen",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Dodano", "col_added": "Dodano",
"col_created_by": "Utworzone przez", "col_created_by": "Utworzone przez",
"col_opened": "Otwarte", "col_opened": "Otwarte",
"col_path": "Lokalizacja" "col_path": "Lokalizacja",
"new_elements": "Nowe elementy"
}, },
"dialogs": { "dialogs": {
"rename_folder": "Zmień nazwę folderu", "rename_folder": "Zmień nazwę folderu",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Adicionado", "col_added": "Adicionado",
"col_created_by": "Criado por", "col_created_by": "Criado por",
"col_opened": "Aberto", "col_opened": "Aberto",
"col_path": "Localização" "col_path": "Localização",
"new_elements": "Novos itens"
}, },
"dialogs": { "dialogs": {
"rename_folder": "Renomear pasta", "rename_folder": "Renomear pasta",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "Добавлено", "col_added": "Добавлено",
"col_created_by": "Создано", "col_created_by": "Создано",
"col_opened": "Открыт", "col_opened": "Открыт",
"col_path": "Расположение" "col_path": "Расположение",
"new_elements": "Новые элементы"
}, },
"dialogs": { "dialogs": {
"rename_folder": "Переименовать папку", "rename_folder": "Переименовать папку",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "新增日期", "col_added": "新增日期",
"col_created_by": "建立者", "col_created_by": "建立者",
"col_opened": "開啟日期", "col_opened": "開啟日期",
"col_path": "位置" "col_path": "位置",
"new_elements": "新項目"
}, },
"dialogs": { "dialogs": {
"rename_folder": "重新命名資料夾", "rename_folder": "重新命名資料夾",
+2 -1
View File
@@ -382,7 +382,8 @@
"col_added": "添加日期", "col_added": "添加日期",
"col_created_by": "创建者", "col_created_by": "创建者",
"col_opened": "打开日期", "col_opened": "打开日期",
"col_path": "位置" "col_path": "位置",
"new_elements": "新元素"
}, },
"dialogs": { "dialogs": {
"rename_folder": "重命名文件夹", "rename_folder": "重命名文件夹",