diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js
index 61c4ea199..089ef4cf7 100644
--- a/src/gui/src/UI/Dashboard/TabFiles.js
+++ b/src/gui/src/UI/Dashboard/TabFiles.js
@@ -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 = {
+
${i18n('name')}
+
${i18n('shared_by')}
+
${i18n('size')}
${i18n('modified')}
@@ -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
+ ${this.currentPath === window.shared_path ? `${sharedByCellHtml(file)}
` : ''}
${file.is_dir ? '
' : `
${this.formatFileSize(file.size)}
`}
${html_encode(formatRelativeTime(file.modified * 1000, Date.now(), window.locale))}
@@ -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 `/
//`
* 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}
*/
- 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)));
},
/**
diff --git a/src/gui/src/UI/Dashboard/TabFiles.test.js b/src/gui/src/UI/Dashboard/TabFiles.test.js
index 401105890..6af1b7099 100644
--- a/src/gui/src/UI/Dashboard/TabFiles.test.js
+++ b/src/gui/src/UI/Dashboard/TabFiles.test.js
@@ -18,7 +18,7 @@
* along with this program. If not, see .
*/
-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('');
+ 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(``);
+ });
+ });
+
+ 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();
+ });
+});
diff --git a/src/gui/src/UI/Dashboard/sharedBy.js b/src/gui/src/UI/Dashboard/sharedBy.js
new file mode 100644
index 000000000..720471722
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/sharedBy.js
@@ -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 .
+ */
+
+// 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) => `${window.html_encode(avatarInitial(name))}`;
+
+/**
+ * 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 '';
+ return ``
+ + avatarHtml(name, 'item-shared-by-avatar')
+ + `${window.html_encode(name)}`
+ + '
';
+};
+
+/**
+ * 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 `';
+ };
+ let h = chip('', `${i18n('shared_filter_everyone')}`, total);
+ for ( const { name, count } of sharers ) {
+ h += chip(
+ name,
+ avatarHtml(name, 'sharer-chip-avatar') + `${window.html_encode(name)}`,
+ count,
+ i18n('shared_by_user', { username: name }, false),
+ );
+ }
+ return h;
+};
diff --git a/src/gui/src/UI/Dashboard/sharedBy.test.js b/src/gui/src/UI/Dashboard/sharedBy.test.js
new file mode 100644
index 000000000..891079d7a
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/sharedBy.test.js
@@ -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 .
+ */
+
+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(/ String(v).replace(/&/g, '&').replace(/ ({ 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');
+ expect(h).toContain('ann');
+ });
+
+ it('encodes the username', () => {
+ const h = sharedByCellHtml(row('"x"'));
+ expect(h).not.toContain('');
+ expect(h).toContain('<b>"x"');
+ });
+
+ it('still renders an empty cell, so the columns line up', () => {
+ expect(sharedByCellHtml({})).toBe('');
+ });
+});
+
+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(/