mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c41db34b | ||
|
|
e4c6d4ab5d | ||
|
|
b9565ef17a | ||
|
|
3486f20543 | ||
|
|
e4888592c7 | ||
|
|
41b519ee75 | ||
|
|
120e2e8bdf | ||
|
|
d71d93c32c | ||
|
|
ec261224a0 | ||
|
|
1d0784c13a | ||
|
|
a9b60630f5 | ||
|
|
b926ad8d1d | ||
|
|
885ea54ef4 | ||
|
|
291aee2e53 | ||
|
|
60bfe4d54e |
No files matched your search
@@ -46,6 +46,7 @@ import { parent_path_for, parse_shared_path, shared_crumbs_for, shared_uids_from
|
||||
import resolve_shared_item from '../../helpers/resolveSharedItem.js';
|
||||
import { is_owned_by_me } from '../../helpers/pathOwner.js';
|
||||
import parse_item_metadata from '../../helpers/parseItemMetadata.js';
|
||||
import { compareSharers, filterBySharer, fitSharedByWidth, sharedByCellHtml, sharerFilterHtml, summarizeSharers } from './sharedBy.js';
|
||||
|
||||
const { html_encode, SelectionArea } = window;
|
||||
|
||||
@@ -168,10 +169,13 @@ const TabFiles = {
|
||||
<button class="path-action-btn upload-btn" title="${i18n('upload')}">${icons.upload}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shared-people-filter" role="group" aria-label="${i18n('shared_filter_people')}" hidden></div>
|
||||
<div class="columns">
|
||||
<div class="item-icon"></div>
|
||||
<div class="item-name sortable" data-sort="name">${i18n('name')}</div>
|
||||
<div class="col-resize-handle" data-resize="name"></div>
|
||||
<div class="item-shared-by sortable" data-sort="shared_by"><span class="sortable-label">${i18n('shared_by')}</span></div>
|
||||
<div class="col-resize-handle" data-resize="sharedBy"></div>
|
||||
<div class="item-size sortable" data-sort="size">${i18n('size')}</div>
|
||||
<div class="col-resize-handle" data-resize="size"></div>
|
||||
<div class="item-modified sortable" data-sort="modified">${i18n('modified')}</div>
|
||||
@@ -356,6 +360,8 @@ const TabFiles = {
|
||||
this.uploadTargetPath = null;
|
||||
// Trash item path → the name it had before it was trashed (renderItem).
|
||||
this.trashedNames = new Map();
|
||||
// The Shared view's people filter: a username, or null for everyone.
|
||||
this.sharerFilter = null;
|
||||
this.folderDwellTimer = null;
|
||||
this.folderDwellTarget = null;
|
||||
this.springLoadedActive = false;
|
||||
@@ -373,7 +379,7 @@ const TabFiles = {
|
||||
this.currentView = 'list';
|
||||
this.sortColumn = 'name';
|
||||
this.sortDirection = 'asc';
|
||||
this.columnWidths = { name: null, size: 100, modified: 120 };
|
||||
this.columnWidths = { name: null, sharedBy: 160, size: 100, modified: 120 };
|
||||
const [savedView, savedSortColumn, savedSortDirection, savedWidths] = await Promise.all([
|
||||
puter.kv.get('view_mode').catch(() => null),
|
||||
puter.kv.get('sort_column').catch(() => null),
|
||||
@@ -384,7 +390,7 @@ const TabFiles = {
|
||||
this.currentView = ['list', 'grid', 'grid-sm'].includes(savedView) ? savedView : 'list';
|
||||
|
||||
// Sorting state
|
||||
this.sortColumn = ['name', 'size', 'modified'].includes(savedSortColumn) ? savedSortColumn : 'name';
|
||||
this.sortColumn = ['name', 'size', 'modified', 'shared_by'].includes(savedSortColumn) ? savedSortColumn : 'name';
|
||||
this.sortDirection = savedSortDirection === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
// Column widths state (for resizing). A corrupt saved value falls back
|
||||
@@ -1610,6 +1616,10 @@ const TabFiles = {
|
||||
this.toggleSelectMode();
|
||||
};
|
||||
|
||||
this.$el_window.find('.header .shared-people-filter').on('click', '.sharer-chip', (e) => {
|
||||
this.setSharerFilter(e.currentTarget.getAttribute('data-sharer') || null);
|
||||
});
|
||||
|
||||
// Column header sorting
|
||||
this.$el_window.find('.header .columns .sortable').on('click', (e) => {
|
||||
const column = $(e.currentTarget).attr('data-sort');
|
||||
@@ -1928,6 +1938,8 @@ const TabFiles = {
|
||||
let $targetColumn;
|
||||
if ( column === 'name' ) {
|
||||
$targetColumn = $header.find('.item-name');
|
||||
} else if ( column === 'sharedBy' ) {
|
||||
$targetColumn = $header.find('.item-shared-by');
|
||||
} else if ( column === 'size' ) {
|
||||
$targetColumn = $header.find('.item-size');
|
||||
} else if ( column === 'modified' ) {
|
||||
@@ -1988,6 +2000,20 @@ const TabFiles = {
|
||||
maxWidth = Math.max(maxWidth, textWidth);
|
||||
}
|
||||
});
|
||||
} else if ( column === 'sharedBy' ) {
|
||||
const $names = $filesTab.find('.files.files-list-view .row:not(.header) .item-shared-by-name');
|
||||
if ( $names.length ) {
|
||||
// Read off a rendered cell: its font, and the padding,
|
||||
// avatar and gap beside the name.
|
||||
const nameStyle = getComputedStyle($names[0]);
|
||||
const font = `${nameStyle.fontWeight} ${nameStyle.fontSize} ${nameStyle.fontFamily}`;
|
||||
const cellStyle = getComputedStyle($names[0].parentElement);
|
||||
const beside = parseFloat(cellStyle.paddingLeft) + parseFloat(cellStyle.paddingRight)
|
||||
+ (parseFloat(cellStyle.columnGap) || 0) + ($names[0].previousElementSibling?.offsetWidth ?? 0);
|
||||
$names.each(function () {
|
||||
maxWidth = Math.max(maxWidth, measureTextWidth(this.textContent, font) + beside);
|
||||
});
|
||||
}
|
||||
} else if ( column === 'size' ) {
|
||||
$filesTab.find('.files.files-list-view .row:not(.header) .item-size').each(function () {
|
||||
const text = $(this).text();
|
||||
@@ -2026,24 +2052,35 @@ const TabFiles = {
|
||||
const $container = $filesTab.find('.files');
|
||||
const containerWidth = $container.width();
|
||||
|
||||
// Only the Shared view has a "Shared by" column.
|
||||
const hasSharedBy = this.currentPath === window.shared_path;
|
||||
let nameWidth = this.columnWidths.name;
|
||||
let sharedByWidth = this.columnWidths.sharedBy || 160;
|
||||
let sizeWidth = this.columnWidths.size || 100;
|
||||
let modifiedWidth = this.columnWidths.modified || 120;
|
||||
|
||||
// Columns that don't fit lay out at the defaults, but the saved
|
||||
// widths stay so they come back once the listing is wide enough.
|
||||
if ( containerWidth > 0 && nameWidth ) {
|
||||
const totalWidth = COLUMN_FIXED_WIDTH + nameWidth + sizeWidth + modifiedWidth;
|
||||
const totalWidth = COLUMN_FIXED_WIDTH + nameWidth + sizeWidth + modifiedWidth
|
||||
+ (hasSharedBy ? sharedByWidth + 4 : 0);
|
||||
if ( totalWidth > containerWidth ) {
|
||||
nameWidth = null;
|
||||
sharedByWidth = 160;
|
||||
sizeWidth = 100;
|
||||
modifiedWidth = 120;
|
||||
}
|
||||
}
|
||||
this.appliedColumnWidths = { name: nameWidth, size: sizeWidth, modified: modifiedWidth };
|
||||
this.appliedColumnWidths = { name: nameWidth, sharedBy: sharedByWidth, size: sizeWidth, modified: modifiedWidth };
|
||||
// Narrowed beside an auto-width name on every pass, so a resize of
|
||||
// another column doesn't save the narrowed width.
|
||||
if ( hasSharedBy && ! nameWidth && containerWidth > 0 ) {
|
||||
sharedByWidth = fitSharedByWidth(sharedByWidth, containerWidth - COLUMN_FIXED_WIDTH - 4 - sizeWidth - modifiedWidth);
|
||||
}
|
||||
|
||||
const nameCol = nameWidth ? `${nameWidth}px` : 'auto';
|
||||
const gridTemplate = `24px ${nameCol} 4px ${sizeWidth}px 4px ${modifiedWidth}px 4px 20px`;
|
||||
const sharedByCols = hasSharedBy ? `${sharedByWidth}px 4px ` : '';
|
||||
const gridTemplate = `24px ${nameCol} 4px ${sharedByCols}${sizeWidth}px 4px ${modifiedWidth}px 4px 20px`;
|
||||
this.columnGridTemplate = gridTemplate;
|
||||
|
||||
$filesTab.find('.header .columns').css('grid-template-columns', gridTemplate);
|
||||
@@ -2086,7 +2123,7 @@ const TabFiles = {
|
||||
* `width` held between the column's minimum and the widest it can be
|
||||
* without the columns overflowing the listing, given the other columns.
|
||||
*
|
||||
* @param {'name'|'size'|'modified'} column
|
||||
* @param {'name'|'sharedBy'|'size'|'modified'} column
|
||||
* @param {number} width
|
||||
* @returns {number}
|
||||
*/
|
||||
@@ -2099,8 +2136,22 @@ const TabFiles = {
|
||||
size: this.columnWidths.size || 100,
|
||||
modified: this.columnWidths.modified || 120,
|
||||
};
|
||||
let fixed = COLUMN_FIXED_WIDTH;
|
||||
// Beside an auto-width name, "Shared by" is narrowed as applyColumnWidths lays it out.
|
||||
const narrowsSharedBy = this.currentPath === window.shared_path && ! this.columnWidths.name;
|
||||
if ( this.currentPath === window.shared_path ) {
|
||||
fixed += 4;
|
||||
widths.sharedBy = this.columnWidths.sharedBy || 160;
|
||||
if ( narrowsSharedBy ) {
|
||||
widths.sharedBy = fitSharedByWidth(widths.sharedBy, containerWidth - fixed - widths.size - widths.modified);
|
||||
}
|
||||
}
|
||||
const others = Object.keys(widths).filter(c => c !== column).reduce((sum, c) => sum + widths[c], 0);
|
||||
return Math.max(min, Math.min(width, containerWidth - COLUMN_FIXED_WIDTH - others));
|
||||
const clamped = Math.max(min, Math.min(width, containerWidth - fixed - others));
|
||||
if ( column === 'sharedBy' && narrowsSharedBy ) {
|
||||
return fitSharedByWidth(clamped, containerWidth - fixed - widths.size - widths.modified);
|
||||
}
|
||||
return clamped;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2281,9 +2332,13 @@ const TabFiles = {
|
||||
{ column: 'size', label: i18n('size') },
|
||||
{ column: 'modified', label: i18n('date_modified') },
|
||||
];
|
||||
if ( this.currentPath === window.shared_path ) {
|
||||
sortOptions.splice(1, 0, { column: 'shared_by', label: i18n('shared_by') });
|
||||
}
|
||||
const sortColumn = this.activeSortColumn();
|
||||
|
||||
const items = sortOptions.map(opt => {
|
||||
const isActive = _this.sortColumn === opt.column;
|
||||
const isActive = sortColumn === opt.column;
|
||||
const directionIcon = _this.sortDirection === 'asc' ? ' ↑' : ' ↓';
|
||||
|
||||
return {
|
||||
@@ -2301,11 +2356,22 @@ const TabFiles = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The column the listing on screen is sorted by. "Shared by" orders only
|
||||
* the Shared view; other folders sort by name until it is changed.
|
||||
*
|
||||
* @returns {'name'|'size'|'modified'|'shared_by'}
|
||||
*/
|
||||
activeSortColumn () {
|
||||
if ( this.sortColumn === 'shared_by' && this.currentPath !== window.shared_path ) return 'name';
|
||||
return this.sortColumn;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sorts an array of files according to current sort settings.
|
||||
*
|
||||
* Folders are always sorted before files. Within each group, items are
|
||||
* sorted by the selected column (name, size, or modified date).
|
||||
* sorted by the selected column (name, size, modified date, or sharer).
|
||||
*
|
||||
* @param {Array<Object>} files - Array of file/folder objects to sort
|
||||
* @returns {Array<Object>} Sorted array with folders first, then files
|
||||
@@ -2323,15 +2389,19 @@ const TabFiles = {
|
||||
}
|
||||
};
|
||||
|
||||
const sortColumn = this.activeSortColumn();
|
||||
const sortFn = (a, b) => {
|
||||
let comparison = 0;
|
||||
const aName = getDisplayName(a);
|
||||
const bName = getDisplayName(b);
|
||||
|
||||
switch ( this.sortColumn ) {
|
||||
switch ( sortColumn ) {
|
||||
case 'name':
|
||||
comparison = aName.localeCompare(bName);
|
||||
break;
|
||||
case 'shared_by':
|
||||
comparison = compareSharers(a, b) || aName.localeCompare(bName);
|
||||
break;
|
||||
case 'size':
|
||||
comparison = (a.size || 0) - (b.size || 0);
|
||||
break;
|
||||
@@ -2373,7 +2443,7 @@ const TabFiles = {
|
||||
const newName = ($newRow.attr('data-name') || file.name || '').toLowerCase();
|
||||
const newSize = file.size || 0;
|
||||
const newModified = file.modified || 0;
|
||||
const sortColumn = this.sortColumn;
|
||||
const sortColumn = this.activeSortColumn();
|
||||
const sortDirection = this.sortDirection;
|
||||
let inserted = false;
|
||||
|
||||
@@ -2629,16 +2699,18 @@ const TabFiles = {
|
||||
* Clicking the same column toggles direction; clicking a new column
|
||||
* sets ascending order. Persists settings and re-renders the directory.
|
||||
*
|
||||
* @param {string} column - Column name to sort by ('name', 'size', or 'modified')
|
||||
* @param {string} column - Column name to sort by ('name', 'size', 'modified', or 'shared_by')
|
||||
* @returns {void}
|
||||
*/
|
||||
handleSort (column) {
|
||||
if ( this.sortColumn === column ) {
|
||||
// Against the column on screen: outside Shared, a saved "Shared by"
|
||||
// sort shows as name, so clicking Name flips that order.
|
||||
if ( this.activeSortColumn() === column ) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = column;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
this.sortColumn = column;
|
||||
|
||||
this.updateSortIndicators();
|
||||
this.refreshKeepingSelection();
|
||||
@@ -2663,10 +2735,76 @@ const TabFiles = {
|
||||
|
||||
$columns.find('.sortable').removeClass('sort-asc sort-desc');
|
||||
|
||||
const $activeColumn = $columns.find(`.sortable[data-sort="${this.sortColumn}"]`);
|
||||
const $activeColumn = $columns.find(`.sortable[data-sort="${this.activeSortColumn()}"]`);
|
||||
$activeColumn.addClass(this.sortDirection === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
},
|
||||
|
||||
/**
|
||||
* Shows the "Shared by" column and people filter in the Shared view only,
|
||||
* and re-lays the columns when the listing moves into or out of it.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
syncSharedViewChrome () {
|
||||
const $filesTab = this.$el_window.find('.files-tab');
|
||||
const isSharedView = this.currentPath === window.shared_path;
|
||||
if ( $filesTab.hasClass('shared-view') === isSharedView ) return;
|
||||
$filesTab.toggleClass('shared-view', isSharedView);
|
||||
if ( ! isSharedView ) $filesTab.find('.shared-people-filter').empty().prop('hidden', true);
|
||||
this.updateSortIndicators();
|
||||
this.applyColumnWidths();
|
||||
},
|
||||
|
||||
/**
|
||||
* In the Shared view, rebuilds the people filter from the full listing
|
||||
* and returns the rows of the person filtered to. Other folders pass
|
||||
* through unchanged.
|
||||
*
|
||||
* @param {Array<Object>} rows
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
applySharerFilter (rows) {
|
||||
if ( this.currentPath !== window.shared_path ) return rows;
|
||||
const sharers = summarizeSharers(rows);
|
||||
// The person's last item went away (unshared, or removed from Shared).
|
||||
if ( ! sharers.some(({ name }) => name === this.sharerFilter) ) this.sharerFilter = null;
|
||||
|
||||
const $bar = this.$el_window.find('.files-tab .shared-people-filter');
|
||||
const bar = $bar[0];
|
||||
// Rebuilding the chips drops focus and scroll; carry both across.
|
||||
const focused = $bar.find('.sharer-chip:focus').attr('data-sharer');
|
||||
const scrollLeft = bar.scrollLeft;
|
||||
const html = sharerFilterHtml(sharers, this.sharerFilter);
|
||||
$bar.html(html).prop('hidden', ! html);
|
||||
bar.scrollLeft = scrollLeft;
|
||||
if ( focused !== undefined ) {
|
||||
$bar.find('.sharer-chip').filter((_, el) => el.getAttribute('data-sharer') === focused)[0]
|
||||
?.focus({ preventScroll: true });
|
||||
}
|
||||
return filterBySharer(rows, this.sharerFilter);
|
||||
},
|
||||
|
||||
/**
|
||||
* Narrows the Shared view to what one person shared, or back to everyone.
|
||||
*
|
||||
* @param {string | null} sharer - Username, or null for everyone
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setSharerFilter (sharer) {
|
||||
if ( this.currentPath !== window.shared_path || this.sharerFilter === sharer ) return;
|
||||
this.sharerFilter = sharer;
|
||||
// The chip answers at once; the rows follow when the listing is back.
|
||||
this.$el_window.find('.files-tab .shared-people-filter .sharer-chip').each(function () {
|
||||
const pressed = (this.getAttribute('data-sharer') || null) === sharer;
|
||||
this.classList.toggle('active', pressed);
|
||||
this.setAttribute('aria-pressed', String(pressed));
|
||||
});
|
||||
await this.renderDirectory(window.shared_path, { filterChange: true });
|
||||
if ( this.currentPath === window.shared_path ) {
|
||||
this.$el_window.find('.files-tab .files').scrollTop(0);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the contents of a directory.
|
||||
*
|
||||
@@ -2678,6 +2816,7 @@ const TabFiles = {
|
||||
* @param {boolean} [options.skipUrlUpdate] - If true, don't update browser URL
|
||||
* @param {boolean} [options.skipNavHistory] - If true, don't add to navigation history
|
||||
* @param {boolean} [options.force] - Supersede a navigation that is still loading
|
||||
* @param {boolean} [options.filterChange] - A people filter change: if Shared can't be listed, filter the rows already loaded
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async renderDirectory (target, options = {}) {
|
||||
@@ -2742,10 +2881,17 @@ const TabFiles = {
|
||||
directoryContents = isSharedView
|
||||
? await this.listSharedView()
|
||||
: await window.puter.fs.readdir(readdirArg);
|
||||
if ( isSharedView ) this.sharedRows = directoryContents;
|
||||
} catch ( err ) {
|
||||
console.error('Failed to read directory:', err);
|
||||
readFailed = true;
|
||||
directoryContents = [];
|
||||
// Filtering needs no server: a filter change still applies to
|
||||
// the rows already loaded.
|
||||
if ( isSharedView && options.filterChange && this.sharedRows ) {
|
||||
directoryContents = this.sharedRows;
|
||||
} else {
|
||||
readFailed = true;
|
||||
directoryContents = [];
|
||||
}
|
||||
}
|
||||
if ( seq !== this.renderSeq ) return;
|
||||
if ( ! directoryContents ) {
|
||||
@@ -2772,6 +2918,13 @@ const TabFiles = {
|
||||
this.currentPath = path || target;
|
||||
}
|
||||
this.loadingFolder = false;
|
||||
// The people filter lasts while the user browses what was shared with
|
||||
// them, so Back out of a shared folder finds it still set. A drag
|
||||
// springing into Shared sees every folder: chips can't be pressed mid-drag.
|
||||
if ( is_owned_by_me(this.currentPath) || (window.an_item_is_being_dragged && ! isSameDirRefresh) ) {
|
||||
this.sharerFilter = null;
|
||||
}
|
||||
this.syncSharedViewChrome();
|
||||
|
||||
// Update browser URL to reflect current file path (only when Files tab is active)
|
||||
if ( !options.skipUrlUpdate && window.is_dashboard_mode && this.isDashboardFilesActive() ) {
|
||||
@@ -2790,6 +2943,7 @@ const TabFiles = {
|
||||
if ( file.path === window.trash_path ) return false;
|
||||
return true;
|
||||
});
|
||||
directoryContents = this.applySharerFilter(directoryContents);
|
||||
|
||||
const isTrashFolder = this.currentPath === window.trash_path;
|
||||
this.updateActionButtons(isTrashFolder);
|
||||
@@ -3205,6 +3359,7 @@ const TabFiles = {
|
||||
</div>
|
||||
<div class="col-spacer"></div>
|
||||
<div class="item-metadata">
|
||||
${this.currentPath === window.shared_path ? `${sharedByCellHtml(file)}<div class="col-spacer"></div>` : ''}
|
||||
${file.is_dir ? '<div class="item-size"></div>' : `<div class="item-size">${this.formatFileSize(file.size)}</div>`}
|
||||
<div class="col-spacer"></div>
|
||||
<div class="item-modified">${html_encode(formatRelativeTime(file.modified * 1000, Date.now(), window.locale))}</div>
|
||||
@@ -3701,7 +3856,13 @@ const TabFiles = {
|
||||
// obscures the drop target under the cursor.
|
||||
cursorAt: { left: -14, top: -12 },
|
||||
cursor: 'grabbing',
|
||||
revert: 'invalid',
|
||||
// As 'invalid', and also when a folder refused the drop (a
|
||||
// read-only share), so the chip slides back rather than vanishing.
|
||||
revert: function (dropped) {
|
||||
const refused = $(this).data('dropRefused');
|
||||
$(this).removeData('dropRefused');
|
||||
return ! dropped || !! refused;
|
||||
},
|
||||
zIndex: 999999999,
|
||||
scroll: false,
|
||||
distance: 5,
|
||||
@@ -3801,7 +3962,13 @@ const TabFiles = {
|
||||
? _this.renderTarget
|
||||
: _this.currentPath;
|
||||
|
||||
void _this.dropRowsInto(itemsToMove, destPath, event);
|
||||
// Shared, or a share the user can only read, takes
|
||||
// nothing: the drag goes back to where it began.
|
||||
if ( _this.acceptsNewItems(destPath) ) {
|
||||
void _this.dropRowsInto(itemsToMove, destPath, event);
|
||||
} else {
|
||||
_this.navigateBackFromSpringLoad();
|
||||
}
|
||||
} else {
|
||||
// Dropped outside file list → cancel, navigate back
|
||||
_this.navigateBackFromSpringLoad();
|
||||
@@ -3844,6 +4011,14 @@ const TabFiles = {
|
||||
// — quietly rename the dragged file to "New Folder".
|
||||
if ( isPending() ) return;
|
||||
|
||||
// A folder shared read-only takes nothing; the server
|
||||
// would refuse each item. The drag goes back instead.
|
||||
if ( _this.isReadOnlyShare($(el_item).attr('data-path')) ) {
|
||||
ui.helper.data('cancelled', true);
|
||||
$(ui.draggable).data('dropRefused', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const draggedPath = $(ui.draggable).attr('data-path');
|
||||
if ( event.ctrlKey && draggedPath?.startsWith(`${window.trash_path}/`) ) {
|
||||
return;
|
||||
@@ -3871,9 +4046,12 @@ const TabFiles = {
|
||||
// not exist yet (see the drop handler above).
|
||||
if ( isPending() ) return;
|
||||
|
||||
$(el_item).addClass('selected');
|
||||
|
||||
const _this = TabFiles;
|
||||
// Not a drop target, but it still opens on a dwell.
|
||||
if ( ! _this.isReadOnlyShare($(el_item).attr('data-path')) ) {
|
||||
$(el_item).addClass('selected');
|
||||
}
|
||||
|
||||
const targetPath = $(el_item).attr('data-path');
|
||||
|
||||
// Don't auto-open the current directory or trash
|
||||
@@ -4725,6 +4903,11 @@ const TabFiles = {
|
||||
await _this.restoreRows([el]);
|
||||
},
|
||||
onPasteInto: (destPath) => _this.pasteClipboardInto(destPath),
|
||||
// The people filter and its counts, the empty notice and the
|
||||
// footer all come from the listing.
|
||||
onRemovedFromShared: () => {
|
||||
if ( _this.currentPath === window.shared_path ) _this.refreshKeepingSelection();
|
||||
},
|
||||
onOpen: (el, fsentry) => {
|
||||
// Custom open handler for Dashboard (avoids window_nav_history issues)
|
||||
if ( fsentry.is_dir ) {
|
||||
@@ -5554,15 +5737,21 @@ const TabFiles = {
|
||||
* Selects the rows for the items a share link names, replacing the
|
||||
* current selection. Matched by uid — the link's `/<owner>/<uid>/<name>`
|
||||
* form carries it, and unlike the name it survives a rename. Values that
|
||||
* aren't shared paths, or items no longer shared, match nothing.
|
||||
* aren't shared paths, or items no longer shared, match nothing. A
|
||||
* people filter that hides one of the items gives way to everyone's.
|
||||
*
|
||||
* @param {string[]} sharedPaths - The link's `?shared=` values
|
||||
* @returns {void}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
selectSharedRows (sharedPaths) {
|
||||
async selectSharedRows (sharedPaths) {
|
||||
const wanted = new Set(shared_uids_from_paths(sharedPaths));
|
||||
if ( wanted.size === 0 ) return;
|
||||
this.selectRowsWhere((row) => wanted.has(String(row.getAttribute('data-uid') ?? '').toLowerCase()));
|
||||
const uidOf = (row) => String(row.getAttribute('data-uid') ?? '').toLowerCase();
|
||||
if ( this.sharerFilter && this.currentPath === window.shared_path ) {
|
||||
const shown = new Set(this.$el_window.find('.files-tab .files .row').map((_, row) => uidOf(row)).get());
|
||||
if ( [...wanted].some((uid) => ! shown.has(uid)) ) await this.setSharerFilter(null);
|
||||
}
|
||||
this.selectRowsWhere((row) => wanted.has(uidOf(row)));
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
// The tab's window and dialog plumbing isn't what these tests exercise.
|
||||
@@ -26,6 +26,7 @@ vi.mock('../UIWindow.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIContextMenu.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../helpers/generateFileContextMenu.js', () => ({ default: vi.fn(async () => []) }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
@@ -36,6 +37,7 @@ globalThis.i18n = globalThis.i18n ?? (key => key);
|
||||
window.icons = {};
|
||||
|
||||
const { default: TabFiles, failedUploadsMessage, rebasePath, typeSelectTarget } = await import('./TabFiles.js');
|
||||
const { default: generateFileContextMenu } = await import('../../helpers/generateFileContextMenu.js');
|
||||
|
||||
const TRASH = '/alice/Trash';
|
||||
|
||||
@@ -535,3 +537,195 @@ describe('TabFiles.applyHiddenRule', () => {
|
||||
expect($('.files .row').first().hasClass('item-revealed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles "Shared by" column width', () => {
|
||||
const SHARED = 'puter://shared';
|
||||
|
||||
beforeEach(() => {
|
||||
// Earlier suites stub applyColumnWidths out.
|
||||
vi.restoreAllMocks();
|
||||
window.shared_path = SHARED;
|
||||
window.trash_path = TRASH;
|
||||
mountListing([], SHARED);
|
||||
TabFiles.columnWidths = { name: null, sharedBy: 160, size: 100, modified: 120 };
|
||||
// jsdom lays nothing out; give the listing a width.
|
||||
vi.spyOn($.fn, 'width').mockImplementation(function () {
|
||||
return this.hasClass('files') ? 530 : 0;
|
||||
});
|
||||
vi.spyOn(TabFiles, 'fitItemNames').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('leaves the auto-width name the larger part in a narrow listing', () => {
|
||||
TabFiles.applyColumnWidths();
|
||||
expect(TabFiles.columnGridTemplate).toBe('24px auto 4px 92px 4px 100px 4px 120px 4px 20px');
|
||||
});
|
||||
|
||||
it('keeps its own width when another column is resized', () => {
|
||||
TabFiles.applyColumnWidths();
|
||||
TabFiles.adoptAppliedColumnWidths();
|
||||
expect(TabFiles.columnWidths.sharedBy).toBe(160);
|
||||
});
|
||||
|
||||
it('leaves other columns room to resize as it is laid out', () => {
|
||||
expect(TabFiles.clampColumnWidth('size', 90)).toBe(90);
|
||||
});
|
||||
|
||||
it('is resized only as far as it is laid out', () => {
|
||||
expect(TabFiles.clampColumnWidth('sharedBy', 200)).toBe(92);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles people filter across navigation', () => {
|
||||
const SHARED = 'puter://shared';
|
||||
const share = (name, sharer) => ({
|
||||
uid: name, name, path: `/${sharer}/${name}-uid/${name}`, is_dir: true, size: 0, modified: 0,
|
||||
shared_with_me: true, shared_by: sharer, owner: sharer,
|
||||
});
|
||||
const listed = () => $('.files .row').map((_, r) => r.getAttribute('data-name')).get().sort();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.user = { username: 'alice', directories: {} };
|
||||
window.home_path = '/alice';
|
||||
window.trash_path = TRASH;
|
||||
window.shared_path = SHARED;
|
||||
window.byte_format = bytes => `${bytes} B`;
|
||||
window.puter = { fs: { readdir: vi.fn(async () => []) } };
|
||||
mountListing([], '/alice');
|
||||
$('.files-tab').prepend('<div class="shared-people-filter" hidden></div>');
|
||||
TabFiles.renderingDirectory = false;
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
TabFiles.sharerFilter = null;
|
||||
// jsdom has no layout to scroll.
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
vi.spyOn(TabFiles, 'listSharedView').mockImplementation(async () => [share('a', 'ann'), share('b', 'bob')]);
|
||||
vi.spyOn(TabFiles, 'applyColumnWidths').mockImplementation(() => {});
|
||||
vi.spyOn(TabFiles, 'renderItem').mockImplementation(async (file) => {
|
||||
$('.files').append(`<div class="row item" data-uid="${file.uid}" data-path="${file.path}" data-name="${file.name}"></div>`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('is still set on coming back out of a shared folder', async () => {
|
||||
await TabFiles.renderDirectory(SHARED);
|
||||
await TabFiles.setSharerFilter('bob');
|
||||
expect(listed()).toEqual(['b']);
|
||||
|
||||
await TabFiles.renderDirectory('/bob/b-uid/b');
|
||||
await TabFiles.renderDirectory(SHARED);
|
||||
|
||||
expect(TabFiles.sharerFilter).toBe('bob');
|
||||
expect(listed()).toEqual(['b']);
|
||||
expect($('.sharer-chip.active').attr('data-sharer')).toBe('bob');
|
||||
});
|
||||
|
||||
it('gives way to show an item a share link or notification names', async () => {
|
||||
const uid = '5298098f-d8a2-41da-828f-867b9a7f0cce';
|
||||
TabFiles.listSharedView.mockImplementation(async () => [{ ...share('a', 'ann'), uid }, share('b', 'bob')]);
|
||||
await TabFiles.renderDirectory(SHARED);
|
||||
await TabFiles.setSharerFilter('bob');
|
||||
|
||||
await TabFiles.selectSharedRows([`/ann/${uid}/a`]);
|
||||
|
||||
expect(TabFiles.sharerFilter).toBeNull();
|
||||
expect(listed()).toEqual(['a', 'b']);
|
||||
expect($('.files .row.selected').attr('data-name')).toBe('a');
|
||||
});
|
||||
|
||||
it('stays put when the named item is already shown', async () => {
|
||||
const uid = '5298098f-d8a2-41da-828f-867b9a7f0cce';
|
||||
TabFiles.listSharedView.mockImplementation(async () => [share('a', 'ann'), { ...share('b', 'bob'), uid }]);
|
||||
await TabFiles.renderDirectory(SHARED);
|
||||
await TabFiles.setSharerFilter('bob');
|
||||
|
||||
await TabFiles.selectSharedRows([`/bob/${uid}/b`]);
|
||||
|
||||
expect(TabFiles.sharerFilter).toBe('bob');
|
||||
expect($('.files .row.selected').attr('data-name')).toBe('b');
|
||||
});
|
||||
|
||||
it('shows every folder to a drag that springs into Shared', async () => {
|
||||
await TabFiles.renderDirectory(SHARED);
|
||||
await TabFiles.setSharerFilter('bob');
|
||||
await TabFiles.renderDirectory('/bob/b-uid/b');
|
||||
|
||||
window.an_item_is_being_dragged = true;
|
||||
try {
|
||||
await TabFiles.renderDirectory(SHARED);
|
||||
} finally {
|
||||
window.an_item_is_being_dragged = false;
|
||||
}
|
||||
|
||||
expect(TabFiles.sharerFilter).toBeNull();
|
||||
expect(listed()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('still filters when Shared fails to list again', async () => {
|
||||
await TabFiles.renderDirectory(SHARED);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
TabFiles.listSharedView.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
|
||||
await TabFiles.setSharerFilter('bob');
|
||||
|
||||
expect(listed()).toEqual(['b']);
|
||||
expect($('.sharer-chip.active').attr('data-sharer')).toBe('bob');
|
||||
expect($('.files').text()).not.toContain("couldn't be opened");
|
||||
});
|
||||
|
||||
it('still says a Shared it was asked to open could not be listed', async () => {
|
||||
await TabFiles.renderDirectory(SHARED);
|
||||
await TabFiles.renderDirectory('/alice/Documents');
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
TabFiles.listSharedView.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
|
||||
await TabFiles.renderDirectory(SHARED);
|
||||
|
||||
expect($('.files').text()).toContain("couldn't be opened");
|
||||
});
|
||||
|
||||
it("clears once the user goes back to their own files", async () => {
|
||||
await TabFiles.renderDirectory(SHARED);
|
||||
await TabFiles.setSharerFilter('bob');
|
||||
|
||||
await TabFiles.renderDirectory('/alice/Documents');
|
||||
await TabFiles.renderDirectory(SHARED);
|
||||
|
||||
expect(TabFiles.sharerFilter).toBeNull();
|
||||
expect(listed()).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles Remove from Shared', () => {
|
||||
const SHARED = 'puter://shared';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.shared_path = SHARED;
|
||||
window.trash_path = TRASH;
|
||||
vi.spyOn(TabFiles, 'refreshKeepingSelection').mockImplementation(async () => {});
|
||||
});
|
||||
|
||||
const removeFromShared = async () => {
|
||||
await TabFiles.generateContextMenuItems($('.files .row')[0], {});
|
||||
generateFileContextMenu.mock.lastCall[0].onRemovedFromShared();
|
||||
};
|
||||
|
||||
it('lists Shared again, for the people filter and the empty notice', async () => {
|
||||
mountListing(['a'], SHARED);
|
||||
await removeFromShared();
|
||||
expect(TabFiles.refreshKeepingSelection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves another folder the user has since opened alone', async () => {
|
||||
mountListing(['a'], '/alice/Documents');
|
||||
await removeFromShared();
|
||||
expect(TabFiles.refreshKeepingSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Who shared each item in the Files tab's Shared view: the row's "Shared by"
|
||||
// cell and the people filter above the listing.
|
||||
|
||||
import { avatarHue, avatarInitial } from '../../helpers/shareAvatar.js';
|
||||
|
||||
/**
|
||||
* @typedef {{ shared_by?: string | null, owner?: string | null }} SharedRow
|
||||
* @typedef {{ name: string, count: number }} Sharer
|
||||
*/
|
||||
|
||||
/**
|
||||
* The person who shared a Shared-view row with the user. A grant recorded
|
||||
* without an issuer falls back to the item's owner.
|
||||
*
|
||||
* @param {SharedRow} [row]
|
||||
* @returns {string} username, or '' when the row names nobody
|
||||
*/
|
||||
export const sharerOf = (row) => row?.shared_by || row?.owner || '';
|
||||
|
||||
/**
|
||||
* Everyone who shared something in `rows`, with how many items each, most
|
||||
* items first and ties by name.
|
||||
*
|
||||
* @param {SharedRow[]} rows
|
||||
* @returns {Sharer[]}
|
||||
*/
|
||||
export const summarizeSharers = (rows) => {
|
||||
const counts = new Map();
|
||||
for ( const row of rows ) {
|
||||
const name = sharerOf(row);
|
||||
if ( name ) counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
return [...counts]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count || compareNames(a.name, b.name));
|
||||
};
|
||||
|
||||
/**
|
||||
* The rows `sharer` shared; every row when `sharer` is empty.
|
||||
*
|
||||
* @template {SharedRow} T
|
||||
* @param {T[]} rows
|
||||
* @param {string | null} [sharer]
|
||||
* @returns {T[]}
|
||||
*/
|
||||
export const filterBySharer = (rows, sharer) => (sharer
|
||||
? rows.filter((row) => sharerOf(row) === sharer)
|
||||
: rows);
|
||||
|
||||
/**
|
||||
* Orders two rows by who shared them, case-insensitively.
|
||||
*
|
||||
* @param {SharedRow} a
|
||||
* @param {SharedRow} b
|
||||
* @returns {number}
|
||||
*/
|
||||
export const compareSharers = (a, b) => compareNames(sharerOf(a), sharerOf(b));
|
||||
|
||||
// One collator for every comparison: localeCompare with options builds a new
|
||||
// one per call, which made sorting a large Shared listing over 20x slower.
|
||||
const compareNames = new Intl.Collator(undefined, { sensitivity: 'base' }).compare;
|
||||
|
||||
/**
|
||||
* The "Shared by" column's width beside an auto-width name column: `width`,
|
||||
* held to 40% of the `room` the two split so the name keeps the larger part,
|
||||
* and never under the 60px every resizable column keeps.
|
||||
*
|
||||
* @param {number} width
|
||||
* @param {number} room
|
||||
* @returns {number}
|
||||
*/
|
||||
export const fitSharedByWidth = (width, room) => Math.max(60, Math.min(width, Math.floor(room * 0.4)));
|
||||
|
||||
/**
|
||||
* The initial-and-color avatar the share modal uses, sized by `className`.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {string} className
|
||||
* @returns {string} markup
|
||||
*/
|
||||
const avatarHtml = (name, className) => `<span class="${className}" style="--share-avatar-hue: ${avatarHue(name)}" aria-hidden="true">${window.html_encode(avatarInitial(name))}</span>`;
|
||||
|
||||
/**
|
||||
* Tooltip for a row's sharer, naming the owner too when someone else re-shared it.
|
||||
*
|
||||
* @param {SharedRow} row
|
||||
* @returns {string} plain text
|
||||
*/
|
||||
export const sharedByTitle = (row) => {
|
||||
const username = sharerOf(row);
|
||||
if ( ! username ) return '';
|
||||
return row.owner && row.owner !== username
|
||||
? i18n('shared_by_user_owned_by', { username, owner: row.owner }, false)
|
||||
: i18n('shared_by_user', { username }, false);
|
||||
};
|
||||
|
||||
/**
|
||||
* A row's "Shared by" cell. Rendered empty when the row names nobody, so the
|
||||
* list view's columns still line up.
|
||||
*
|
||||
* @param {SharedRow} row
|
||||
* @returns {string} markup
|
||||
*/
|
||||
export const sharedByCellHtml = (row) => {
|
||||
const name = sharerOf(row);
|
||||
if ( ! name ) return '<div class="item-shared-by"></div>';
|
||||
return `<div class="item-shared-by" title="${window.html_encode(sharedByTitle(row))}">`
|
||||
+ avatarHtml(name, 'item-shared-by-avatar')
|
||||
+ `<span class="item-shared-by-name">${window.html_encode(name)}</span>`
|
||||
+ '</div>';
|
||||
};
|
||||
|
||||
/**
|
||||
* The people filter's chips: "Everyone", then one per sharer. Empty with
|
||||
* fewer than two sharers, where filtering would change nothing.
|
||||
*
|
||||
* @param {Sharer[]} sharers - From summarizeSharers
|
||||
* @param {string | null} active - The sharer filtered to, or null for everyone
|
||||
* @returns {string} markup
|
||||
*/
|
||||
export const sharerFilterHtml = (sharers, active) => {
|
||||
if ( sharers.length < 2 ) return '';
|
||||
const total = sharers.reduce((sum, sharer) => sum + sharer.count, 0);
|
||||
const chip = (value, inner, count, title) => {
|
||||
const pressed = (active ?? '') === value;
|
||||
return `<button type="button" class="sharer-chip${pressed ? ' active' : ''}" data-sharer="${window.html_encode(value)}" aria-pressed="${pressed}"${title ? ` title="${window.html_encode(title)}"` : ''}>`
|
||||
+ inner
|
||||
+ `<span class="sharer-chip-count">${count}</span>`
|
||||
+ '</button>';
|
||||
};
|
||||
let h = chip('', `<span class="sharer-chip-label">${i18n('shared_filter_everyone')}</span>`, total);
|
||||
for ( const { name, count } of sharers ) {
|
||||
h += chip(
|
||||
name,
|
||||
avatarHtml(name, 'sharer-chip-avatar') + `<span class="sharer-chip-label">${window.html_encode(name)}</span>`,
|
||||
count,
|
||||
i18n('shared_by_user', { username: name }, false),
|
||||
);
|
||||
}
|
||||
return h;
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Mirrors the real i18n: named placeholders, encoded unless the third argument is false.
|
||||
globalThis.i18n = (key, args, encode = true) => {
|
||||
const raw = `${key}:${Object.values(args ?? {}).join(',')}`;
|
||||
return encode ? raw.replace(/&/g, '&').replace(/</g, '<') : raw;
|
||||
};
|
||||
globalThis.window = {
|
||||
html_encode: (v) => String(v).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"'),
|
||||
};
|
||||
|
||||
const {
|
||||
compareSharers,
|
||||
filterBySharer,
|
||||
fitSharedByWidth,
|
||||
sharedByCellHtml,
|
||||
sharedByTitle,
|
||||
sharerFilterHtml,
|
||||
sharerOf,
|
||||
summarizeSharers,
|
||||
} = await import('./sharedBy.js');
|
||||
|
||||
const row = (shared_by, owner = shared_by, name = 'f') => ({ shared_by, owner, name });
|
||||
|
||||
describe('sharerOf', () => {
|
||||
it('names whoever granted the share', () => {
|
||||
expect(sharerOf(row('ann', 'bob'))).toBe('ann');
|
||||
});
|
||||
|
||||
it('falls back to the owner for a grant with no issuer', () => {
|
||||
expect(sharerOf({ shared_by: null, owner: 'bob' })).toBe('bob');
|
||||
});
|
||||
|
||||
it('is empty when the row names nobody', () => {
|
||||
expect(sharerOf({})).toBe('');
|
||||
expect(sharerOf(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeSharers', () => {
|
||||
it('counts each sharer, most items first', () => {
|
||||
const sharers = summarizeSharers([row('ann'), row('bob'), row('bob'), row('cy')]);
|
||||
expect(sharers).toEqual([
|
||||
{ name: 'bob', count: 2 },
|
||||
{ name: 'ann', count: 1 },
|
||||
{ name: 'cy', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders ties by name, ignoring case', () => {
|
||||
expect(summarizeSharers([row('Zed'), row('amy'), row('Bo')]).map((s) => s.name))
|
||||
.toEqual(['amy', 'Bo', 'Zed']);
|
||||
});
|
||||
|
||||
it('skips rows that name nobody', () => {
|
||||
expect(summarizeSharers([{}, row('ann')])).toEqual([{ name: 'ann', count: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterBySharer', () => {
|
||||
const rows = [row('ann', 'ann', 'a'), row('bob', 'bob', 'b'), row(null, 'ann', 'c')];
|
||||
|
||||
it('keeps only what that person shared, owner fallback included', () => {
|
||||
expect(filterBySharer(rows, 'ann').map((r) => r.name)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('keeps everything without a filter', () => {
|
||||
expect(filterBySharer(rows, null)).toBe(rows);
|
||||
expect(filterBySharer(rows, '')).toBe(rows);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareSharers', () => {
|
||||
it('sorts by sharer, ignoring case', () => {
|
||||
const sorted = [row('carl'), row('Ann'), row('bob')].sort(compareSharers);
|
||||
expect(sorted.map(sharerOf)).toEqual(['Ann', 'bob', 'carl']);
|
||||
});
|
||||
|
||||
it('treats accented and plain letters alike', () => {
|
||||
expect(compareSharers(row('José'), row('jose'))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitSharedByWidth', () => {
|
||||
it('keeps the width while the name has the larger part', () => {
|
||||
expect(fitSharedByWidth(160, 1200)).toBe(160);
|
||||
});
|
||||
|
||||
it('yields to the name in a narrow listing', () => {
|
||||
// A 530px listing leaves 230px for the name and "Shared by" together.
|
||||
expect(fitSharedByWidth(160, 230)).toBe(92);
|
||||
});
|
||||
|
||||
it('never goes under the column minimum', () => {
|
||||
expect(fitSharedByWidth(160, 50)).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedByTitle', () => {
|
||||
it('names the sharer', () => {
|
||||
expect(sharedByTitle(row('ann'))).toBe('shared_by_user:ann');
|
||||
});
|
||||
|
||||
it('names the owner too when someone else re-shared it', () => {
|
||||
expect(sharedByTitle(row('ann', 'bob'))).toBe('shared_by_user_owned_by:ann,bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedByCellHtml', () => {
|
||||
it('shows an avatar and the sharer', () => {
|
||||
const h = sharedByCellHtml(row('ann'));
|
||||
expect(h).toContain('class="item-shared-by"');
|
||||
expect(h).toContain('>A</span>');
|
||||
expect(h).toContain('<span class="item-shared-by-name">ann</span>');
|
||||
});
|
||||
|
||||
it('encodes the username', () => {
|
||||
const h = sharedByCellHtml(row('<b>"x"'));
|
||||
expect(h).not.toContain('<b>');
|
||||
expect(h).toContain('<b>"x"');
|
||||
});
|
||||
|
||||
it('still renders an empty cell, so the columns line up', () => {
|
||||
expect(sharedByCellHtml({})).toBe('<div class="item-shared-by"></div>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharerFilterHtml', () => {
|
||||
it('is empty with fewer than two sharers', () => {
|
||||
expect(sharerFilterHtml([], null)).toBe('');
|
||||
expect(sharerFilterHtml([{ name: 'ann', count: 3 }], null)).toBe('');
|
||||
});
|
||||
|
||||
it('leads with Everyone, counting every item', () => {
|
||||
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null);
|
||||
const chips = h.match(/<button[^>]*>/g);
|
||||
expect(chips).toHaveLength(3);
|
||||
expect(chips[0]).toContain('data-sharer=""');
|
||||
expect(chips[0]).toContain('aria-pressed="true"');
|
||||
expect(h).toContain('shared_filter_everyone:</span><span class="sharer-chip-count">3</span>');
|
||||
expect(chips[1]).toContain('data-sharer="bob"');
|
||||
});
|
||||
|
||||
it('presses the active sharer only', () => {
|
||||
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], 'ann');
|
||||
const pressed = h.match(/<button[^>]*aria-pressed="true"[^>]*>/g);
|
||||
expect(pressed).toHaveLength(1);
|
||||
expect(pressed[0]).toContain('data-sharer="ann"');
|
||||
expect(pressed[0]).toContain('sharer-chip active');
|
||||
});
|
||||
|
||||
it('encodes usernames in values and labels', () => {
|
||||
const h = sharerFilterHtml([{ name: 'a"b', count: 1 }, { name: '<i>', count: 1 }], null);
|
||||
expect(h).toContain('data-sharer="a"b"');
|
||||
expect(h).not.toContain('<i>');
|
||||
});
|
||||
});
|
||||
@@ -3008,7 +3008,7 @@ select.share-modal-link-access {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: hsla(var(--share-avatar-hue, 213), 60%, 45%, 0.14);
|
||||
color: hsl(var(--share-avatar-hue, 213), 45%, 38%);
|
||||
color: hsl(var(--share-avatar-hue, 213), 45%, 28%);
|
||||
}
|
||||
.share-modal-row-who {
|
||||
flex: 1 1 auto;
|
||||
@@ -3251,7 +3251,7 @@ select.share-modal-row-mode:focus {
|
||||
.share-modal-avatar,
|
||||
.share-modal .share-suggest-avatar {
|
||||
background: hsla(var(--share-avatar-hue, 213), 55%, 55%, 0.2);
|
||||
color: hsl(var(--share-avatar-hue, 213), 55%, 72%);
|
||||
color: hsl(var(--share-avatar-hue, 213), 55%, 76%);
|
||||
}
|
||||
/* Restated after the rule above, which would otherwise repaint the glyph
|
||||
variant with a hue it does not carry. */
|
||||
@@ -4900,6 +4900,189 @@ body.myapps-reordering .myapps-tile {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* -- Shared view: who shared each item --
|
||||
The "Shared by" column, a sharer line on grid tiles, and the people filter.
|
||||
TabFiles.js sets .shared-view on the Shared view only. */
|
||||
.dashboard-section-files .files-tab:not(.shared-view) .header .columns .item-shared-by,
|
||||
.dashboard-section-files .files-tab:not(.shared-view) .header .columns [data-resize="sharedBy"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Its two-word label fits on one line, ending in an ellipsis once narrowed. */
|
||||
.dashboard-section-files .header .columns .item-shared-by .sortable-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-section-files .row .item-shared-by {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
color: var(--dashboard-text-tertiary);
|
||||
}
|
||||
|
||||
.dashboard-section-files .row.selected .item-shared-by {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dashboard-section-files .item-shared-by-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The share modal's initial-and-color avatar, at row size. */
|
||||
.dashboard-section-files .item-shared-by-avatar,
|
||||
.dashboard-section-files .sharer-chip-avatar {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
background: hsla(var(--share-avatar-hue, 213), 60%, 45%, 0.14);
|
||||
color: hsl(var(--share-avatar-hue, 213), 45%, 28%);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dashboard-section-files .item-shared-by-avatar,
|
||||
.dashboard-section-files .sharer-chip-avatar {
|
||||
background: hsla(var(--share-avatar-hue, 213), 55%, 55%, 0.2);
|
||||
color: hsl(var(--share-avatar-hue, 213), 55%, 76%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid tiles: the sharer on a line under the name. */
|
||||
.dashboard-section-files .files-tab .files.files-grid-view .row .item-metadata {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-grid-view .row .item-shared-by {
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
margin-top: -4px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-grid-view .row .item-shared-by-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-grid-view.files-grid-view-sm .row .item-shared-by {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* People filter: one chip per person, scrolling sideways when they don't fit.
|
||||
The edges fade inside the bar's own padding, so a chip at rest never does. */
|
||||
.dashboard-section-files .shared-people-filter {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
-webkit-mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent);
|
||||
mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent);
|
||||
}
|
||||
|
||||
.dashboard-section-files .shared-people-filter[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-section-files .shared-people-filter::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-section-files .sharer-chip {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 220px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 999px;
|
||||
background: var(--dashboard-card-background);
|
||||
color: var(--dashboard-text);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
/* The avatar sits in the chip's rounded end, as an input chip's does. */
|
||||
.dashboard-section-files .sharer-chip-avatar {
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
.dashboard-section-files .sharer-chip-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-section-files .sharer-chip-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-section-files .sharer-chip:hover {
|
||||
background: var(--select-tint-weak);
|
||||
border-color: var(--select-tint-strong);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-section-files .sharer-chip.active {
|
||||
background: var(--select-tint);
|
||||
border-color: var(--select-ring);
|
||||
color: var(--select-text);
|
||||
}
|
||||
|
||||
.dashboard-section-files .sharer-chip.active .sharer-chip-count {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Inset, so the bar's scroll box doesn't clip the ring. */
|
||||
.dashboard-section-files .sharer-chip:focus-visible {
|
||||
outline: 2px solid var(--select-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.dashboard-section-files .sharer-chip {
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-section-files .sharer-chip {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-grid-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
@@ -5712,6 +5895,40 @@ body.myapps-reordering .myapps-tile {
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
/* Shared view: the sharer leads the second line. */
|
||||
.dashboard-section-files .files-tab .files.files-list-view .row .item-shared-by {
|
||||
flex: 0 1 auto;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
line-height: 24px;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-list-view .row .item-shared-by-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-list-view .row .item-shared-by:not(:empty)::after {
|
||||
content: '•';
|
||||
flex: none;
|
||||
margin: 0 6px 0 2px;
|
||||
}
|
||||
|
||||
/* A long sharer name ends in an ellipsis instead of pushing the date off
|
||||
the row and under the ⋯ button, which the name line keeps clear too. */
|
||||
.dashboard-section-files .files-tab.shared-view .files.files-list-view .row .item-metadata {
|
||||
min-width: 0;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab.shared-view .files.files-list-view .row .item-size,
|
||||
.dashboard-section-files .files-tab.shared-view .files.files-list-view .row .item-modified {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Hide outer spacers */
|
||||
.dashboard-section-files .files-tab .files.files-list-view .row > .col-spacer {
|
||||
display: none;
|
||||
|
||||
@@ -6880,7 +6880,7 @@ html.dark-mode .usage-table-fade-overlay {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
background: hsla(var(--share-avatar-hue, 213), 60%, 45%, 0.14);
|
||||
color: hsl(var(--share-avatar-hue, 213), 45%, 38%);
|
||||
color: hsl(var(--share-avatar-hue, 213), 45%, 28%);
|
||||
}
|
||||
|
||||
/* A team is not a person, so it gets a glyph rather than an initial. */
|
||||
|
||||
@@ -48,6 +48,7 @@ import { is_default_app } from './defaultApp.js';
|
||||
* @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element}
|
||||
* @param {Function} options.onShare - Optional custom share handler (used by Dashboard); receives {name, path, uid, element}
|
||||
* @param {Function} options.onPasteInto - Optional custom paste handler (used by Dashboard); receives the folder's path
|
||||
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has taken the item's row away (used by Dashboard)
|
||||
* @returns {Promise<Array>} Array of context menu items
|
||||
*/
|
||||
const generate_file_context_menu = async function (options) {
|
||||
@@ -383,6 +384,7 @@ const generate_file_context_menu = async function (options) {
|
||||
// walked away from.
|
||||
invalidate_shared_roots();
|
||||
$(el_item).remove();
|
||||
options.onRemovedFromShared?.();
|
||||
} catch (e) {
|
||||
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
|
||||
}
|
||||
|
||||
@@ -493,6 +493,10 @@ const en = {
|
||||
shared: 'Shared',
|
||||
shared_with_me: 'Shared with me',
|
||||
shared_by: 'Shared by',
|
||||
shared_by_user: 'Shared by {{username}}',
|
||||
shared_by_user_owned_by: 'Shared by {{username}}, owned by {{owner}}',
|
||||
shared_filter_everyone: 'Everyone',
|
||||
shared_filter_people: 'Filter by who shared',
|
||||
shared_link_other_account: 'This link was sent to a different Puter account. Sign in to that account to see what was shared with it.',
|
||||
shared_link_sign_in: 'Sign in to another account',
|
||||
shared_link_continue_as: 'Continue as {{username}}',
|
||||
|
||||
Reference in new issue
Block a user