Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 697fa58b97 Show an item as shared by the person the Shared view is filtered to
An item several people shared showed the strongest grant's issuer in
the Shared by column even when the listing was filtered to one of the
others. A filtered row now shows the person filtered to, which also
keeps the Shared by sort in step with what the column displays.
2026-11-09 14:42:56 -07:00
Nariman Jelveh ba9c0a8971 Keep an item listed after Remove from Shared when a team still shares it
Leaving an item drops the grants made to the user directly. One a team
also holds stays shared, but the desktop took its icon away until the
next refresh and the dashboard removed the row before re-listing it. A
merged row now records that a team holds the item too. The desktop
re-lists the Shared folder so the item shows as the team share it has
become, and the dashboard leaves the row in place for its refresh.
2026-11-09 14:37:05 -07:00
Nariman Jelveh 290e01fe18 Keep a shared item under each person who shared it in the people filter
A merged row carried only the strongest grant's issuer, so filtering the
Shared view to someone whose grant was the weaker one hid an item they
shared, and their chip left it out of its count. A merged row now lists
every issuer. The filter and its counts match any of them, Everyone
counts the item once, and the Shared by tooltip names them all.
2026-11-09 14:35:30 -07:00
Nariman Jelveh 5c2b4da9f0 Show a shared item once, with its strongest access, when it has more than one share
The Shared listing returns one row per grant, so an item shared by two
people, or directly and through a team, appeared twice and took its
actions from whichever grant was listed last. The GUI now merges the
rows for an item and keeps the strongest mode.
2026-11-09 14:30:38 -07:00
16 changed files with 435 additions and 26 deletions

No files matched your search

+4 -2
View File
@@ -50,7 +50,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';
import { compareSharers, filterBySharer, fitSharedByWidth, sharedByCellHtml, sharerFilterHtml, sharerOf, summarizeSharers } from './sharedBy.js';
import { paintAvatars } from '../../helpers/profilePictures.js';
const { html_encode, SelectionArea } = window;
@@ -2983,7 +2983,7 @@ const TabFiles = {
// 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);
const html = sharerFilterHtml(sharers, this.sharerFilter, rows.filter((row) => sharerOf(row)).length);
$bar.html(html).prop('hidden', ! html);
paintAvatars(bar);
bar.scrollLeft = scrollLeft;
@@ -6011,8 +6011,10 @@ const TabFiles = {
shared_with_me: true,
share_mode: share.mode,
shared_by: share.issuer,
shared_by_all: share.issuers,
owner: share.owner,
holder_team: share.holderTeam,
also_team_share: share.alsoHeldByTeam,
}));
const linked = this.pendingSharedLinks ?? [];
+28
View File
@@ -1667,6 +1667,34 @@ describe('TabFiles listSharedView', () => {
});
expect(rows[1].holder_team).toEqual(team);
});
it('merges the grants on one item into a single row', async () => {
const team = { uid: 't-1', name: 'Acme', handle: 'acme' };
const grant = { entryUid: 'u-1', name: 'a.txt', path: '/bob/u-1/a.txt', isDir: false, owner: 'bob' };
window.puter = {
fs: {
listShared: vi.fn(async () => ({
items: [
{ ...grant, mode: 'read', issuer: 'bob', holderTeam: team },
{ ...grant, mode: 'manage', issuer: 'bob' },
{ ...grant, mode: 'read', issuer: 'carol' },
],
})),
},
};
const rows = await TabFiles.listSharedView();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
uid: 'u-1',
share_mode: 'manage',
shared_by: 'bob',
shared_by_all: ['bob', 'carol'],
also_team_share: true,
});
expect(rows[0].holder_team).toBeUndefined();
});
});
describe('TabFiles path field', () => {
+43 -11
View File
@@ -22,9 +22,10 @@
import { avatarHue, avatarInitial } from '../../helpers/shareAvatar.js';
import { avatarUserAttr } from '../../helpers/profilePictures.js';
import { intlLocale } from '../../i18n/intlLocale.js';
/**
* @typedef {{ shared_by?: string | null, owner?: string | null }} SharedRow
* @typedef {{ shared_by?: string | null, shared_by_all?: string[], owner?: string | null }} SharedRow
* @typedef {{ name: string, count: number }} Sharer
*/
@@ -37,6 +38,19 @@ import { avatarUserAttr } from '../../helpers/profilePictures.js';
*/
export const sharerOf = (row) => row?.shared_by || row?.owner || '';
/**
* Everyone who shared a row with the user, `sharerOf` first. More than one
* when the item was granted by several people.
*
* @param {SharedRow} [row]
* @returns {string[]} usernames
*/
export const sharersOf = (row) => {
const first = sharerOf(row);
if ( ! row?.shared_by_all?.length ) return first ? [first] : [];
return [...new Set([first, ...row.shared_by_all])].filter(Boolean);
};
/**
* Everyone who shared something in `rows`, with how many items each, most
* items first and ties by name.
@@ -47,8 +61,7 @@ export const sharerOf = (row) => row?.shared_by || row?.owner || '';
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);
for ( const name of sharersOf(row) ) counts.set(name, (counts.get(name) ?? 0) + 1);
}
return [...counts]
.map(([name, count]) => ({ name, count }))
@@ -56,7 +69,8 @@ export const summarizeSharers = (rows) => {
};
/**
* The rows `sharer` shared; every row when `sharer` is empty.
* The rows `sharer` shared, each shown as shared by them; every row when
* `sharer` is empty.
*
* @template {SharedRow} T
* @param {T[]} rows
@@ -64,7 +78,9 @@ export const summarizeSharers = (rows) => {
* @returns {T[]}
*/
export const filterBySharer = (rows, sharer) => (sharer
? rows.filter((row) => sharerOf(row) === sharer)
? rows
.filter((row) => sharersOf(row).includes(sharer))
.map((row) => (sharerOf(row) === sharer ? row : { ...row, shared_by: sharer }))
: rows);
/**
@@ -80,6 +96,21 @@ export const compareSharers = (a, b) => compareNames(sharerOf(a), sharerOf(b));
// one per call, which made sorting a large Shared listing over 20x slower.
const compareNames = new Intl.Collator(undefined, { sensitivity: 'base' }).compare;
/**
* Names joined the way the user's language lists them ("ann and bob").
*
* @param {string[]} names
* @returns {string}
*/
const listNames = (names) => {
if ( names.length === 1 ) return names[0];
try {
return new Intl.ListFormat(intlLocale(window.locale ?? 'en'), { type: 'conjunction' }).format(names);
} catch {
return names.join(', ');
}
};
/**
* 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,
@@ -102,15 +133,16 @@ export const fitSharedByWidth = (width, room) => Math.max(60, Math.min(width, Ma
const avatarHtml = (name, className) => `<span class="${className}" style="--share-avatar-hue: ${avatarHue(name)}"${avatarUserAttr(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.
* Tooltip for a row's sharers, naming the owner too when none of them owns 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
const sharers = sharersOf(row);
if ( sharers.length === 0 ) return '';
const username = listNames(sharers);
return row.owner && ! sharers.includes(row.owner)
? i18n('shared_by_user_owned_by', { username, owner: row.owner }, false)
: i18n('shared_by_user', { username }, false);
};
@@ -137,11 +169,11 @@ export const sharedByCellHtml = (row) => {
*
* @param {Sharer[]} sharers - From summarizeSharers
* @param {string | null} active - The sharer filtered to, or null for everyone
* @param {number} total - Items anyone shared; an item counts once however many shared it
* @returns {string} markup
*/
export const sharerFilterHtml = (sharers, active) => {
export const sharerFilterHtml = (sharers, active, total) => {
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)}"` : ''}>`
+39 -1
View File
@@ -40,6 +40,13 @@ const {
} = await import('./sharedBy.js');
const row = (shared_by, owner = shared_by, name = 'f') => ({ shared_by, owner, name });
// An item granted by more than one person: `shared_by` holds the strongest grant's.
const multiRow = (shared_by_all, owner = shared_by_all[0], name = 'f') => ({
shared_by: shared_by_all[0],
shared_by_all,
owner,
name,
});
describe('sharerOf', () => {
it('names whoever granted the share', () => {
@@ -74,6 +81,13 @@ describe('summarizeSharers', () => {
it('skips rows that name nobody', () => {
expect(summarizeSharers([{}, row('ann')])).toEqual([{ name: 'ann', count: 1 }]);
});
it('counts an item for everyone who shared it', () => {
expect(summarizeSharers([multiRow(['bob', 'ann']), row('ann')])).toEqual([
{ name: 'ann', count: 2 },
{ name: 'bob', count: 1 },
]);
});
});
describe('filterBySharer', () => {
@@ -83,6 +97,20 @@ describe('filterBySharer', () => {
expect(filterBySharer(rows, 'ann').map((r) => r.name)).toEqual(['a', 'c']);
});
it('keeps an item for each person who shared it', () => {
const shared = [multiRow(['bob', 'ann'], 'ann', 'both'), row('cy', 'cy', 'c')];
expect(filterBySharer(shared, 'ann').map((r) => r.name)).toEqual(['both']);
expect(filterBySharer(shared, 'bob').map((r) => r.name)).toEqual(['both']);
});
it('shows a kept item as shared by the person filtered to', () => {
const [both, own] = filterBySharer([multiRow(['bob', 'ann'], 'ann', 'both'), row('ann', 'ann', 'own')], 'ann');
expect(sharerOf(both)).toBe('ann');
expect(sharedByCellHtml(both)).toContain('<span class="item-shared-by-name">ann</span>');
expect(sharedByTitle(both)).toBe('shared_by_user:ann and bob');
expect(sharerOf(own)).toBe('ann');
});
it('keeps everything without a filter', () => {
expect(filterBySharer(rows, null)).toBe(rows);
expect(filterBySharer(rows, '')).toBe(rows);
@@ -123,6 +151,11 @@ describe('sharedByTitle', () => {
it('names the owner too when someone else re-shared it', () => {
expect(sharedByTitle(row('ann', 'bob'))).toBe('shared_by_user_owned_by:ann,bob');
});
it('names everyone who shared the item', () => {
expect(sharedByTitle(multiRow(['bob', 'ann'], 'ann'))).toBe('shared_by_user:bob and ann');
expect(sharedByTitle(multiRow(['bob', 'cy'], 'ann'))).toBe('shared_by_user_owned_by:bob and cy,ann');
});
});
describe('sharedByCellHtml', () => {
@@ -155,7 +188,7 @@ describe('sharerFilterHtml', () => {
});
it('leads with Everyone, counting every item', () => {
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null);
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null, 3);
const chips = h.match(/<button[^>]*>/g);
expect(chips).toHaveLength(3);
expect(chips[0]).toContain('data-sharer=""');
@@ -165,6 +198,11 @@ describe('sharerFilterHtml', () => {
expect(h).toContain('data-avatar-user="bob"');
});
it('counts an item shared by two people once under Everyone', () => {
const h = sharerFilterHtml([{ name: 'ann', count: 2 }, { name: 'bob', count: 1 }], null, 2);
expect(h).toContain('shared_filter_everyone:</span><span class="sharer-chip-count">2</span>');
});
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);
+10 -1
View File
@@ -35,6 +35,7 @@ import { is_owned_by_me } from '../helpers/pathOwner.js';
import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from '../helpers/sharedAccess.js';
import { is_default_app } from '../helpers/defaultApp.js';
import openWithAI from '../helpers/openWithAI.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
async function UIItem (options) {
const matching_appendto_count = $(options.appendTo).length;
@@ -64,6 +65,7 @@ async function UIItem (options) {
options.share_mode = options.share_mode ?? '';
options.shared_by = options.shared_by ?? '';
options.is_team_share = options.is_team_share ?? false;
options.is_also_team_share = options.is_also_team_share ?? false;
options.owner = options.owner ?? '';
options.metadata = options.metadata ?? '';
options.multiselectable = (options.multiselectable === undefined || options.multiselectable === true) ? true : false;
@@ -102,6 +104,7 @@ async function UIItem (options) {
data-share_mode="${html_encode(options.share_mode)}"
data-shared_by="${html_encode(options.shared_by)}"
data-is_team_share="${options.is_team_share ? 1 : 0}"
data-is_also_team_share="${options.is_also_team_share ? 1 : 0}"
data-owner="${html_encode(options.owner)}"
data-has_website="${show_website_badge ? 1 : 0 }"
data-website_url = "${website_url ? html_encode(website_url) : ''}"
@@ -1529,7 +1532,13 @@ async function UIItem (options) {
// Or mode lookups keep answering for a share we
// just walked away from.
invalidate_shared_roots();
$(el_item).removeItems();
// A team that still shares it keeps it listed,
// as the team's share.
if ( $(el_item).attr('data-is_also_team_share') === '1' ) {
refresh_item_container($(el_item).closest('.item-container'));
} else {
$(el_item).removeItems();
}
} catch (e) {
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
}
+30 -1
View File
@@ -33,6 +33,7 @@ vi.mock('../helpers/launchApp.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/openItem.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/publishAsWebsite.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/openWithAI.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/refreshItemContainer.js', () => ({ default: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
@@ -67,6 +68,8 @@ Object.assign(window, {
const { default: UIItem } = await import('./UIItem.js');
const { default: UIContextMenu } = await import('./UIContextMenu.js');
const { default: refresh_item_container } =
await import('../helpers/refreshItemContainer.js');
const menuLabels = async (options) => {
document.body.innerHTML =
@@ -90,14 +93,25 @@ const menuLabels = async (options) => {
return UIContextMenu.mock.calls[0][0].items.map((item) => item?.html);
};
const removeFromShared = async (options) => {
await menuLabels(options);
await UIContextMenu.mock.calls[0][0].items
.find((item) => item?.html === 'share_remove_from_shared')
.onClick();
};
describe('UIItem Remove from Shared', () => {
beforeEach(() => {
window.user = { username: 'alice' };
window.trash_path = '/alice/Trash';
window.puter = {
fs: { listShared: vi.fn(async () => ({ items: [] })) },
fs: {
listShared: vi.fn(async () => ({ items: [] })),
unshare: vi.fn(async () => ({ revoked: 1 })),
},
};
globalThis.puter = window.puter;
refresh_item_container.mockClear();
});
it('is offered on an item shared with the user directly', async () => {
@@ -109,4 +123,19 @@ describe('UIItem Remove from Shared', () => {
expect($('.item').attr('data-is_team_share')).toBe('1');
expect(labels).not.toContain('share_remove_from_shared');
});
it('takes the item away once the user has left it', async () => {
await removeFromShared({});
expect(puter.fs.unshare).toHaveBeenCalledWith('/bob/u-1/Photos', 'alice');
expect($('.item')).toHaveLength(0);
expect(refresh_item_container).not.toHaveBeenCalled();
});
it('lists the item again when a team still shares it', async () => {
await removeFromShared({ is_also_team_share: true });
expect(puter.fs.unshare).toHaveBeenCalledWith('/bob/u-1/Photos', 'alice');
expect($('.item')).toHaveLength(1);
expect(refresh_item_container).toHaveBeenCalledTimes(1);
expect(refresh_item_container.mock.calls[0][0][0]).toBe($('.item-container')[0]);
});
});
@@ -50,7 +50,7 @@ import { is_default_app } from './defaultApp.js';
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
* @param {Function} options.onPublishWorker - Optional custom publish-as-worker handler (used by Dashboard); receives {uid, name, path}
* @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)
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has dropped the user's own access (used by Dashboard)
* @returns {Promise<Array>} Array of context menu items
*/
const generate_file_context_menu = async function (options) {
@@ -389,7 +389,9 @@ const generate_file_context_menu = async function (options) {
// Or mode lookups keep answering for a share we just
// walked away from.
invalidate_shared_roots();
$(el_item).remove();
// A team that still shares it keeps it listed, as the
// team's share; the refreshed listing shows it that way.
if ( fsentry.also_team_share !== true ) $(el_item).remove();
options.onRemovedFromShared?.();
} catch (e) {
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
@@ -89,6 +89,38 @@ it('does not offer it on a share held through a team', async () => {
);
});
describe('Remove from Shared', () => {
const remove = async (fsentry) => {
const row = sharedRow();
document.body.append(row);
const onRemovedFromShared = vi.fn();
window.user = { username: 'me' };
globalThis.puter = { fs: { unshare: vi.fn(async () => ({ revoked: 1 })) } };
const items = await generate_file_context_menu({
element: row,
fsentry: { name: 'notes.txt', path: '/ann/uid-1/notes.txt', ...fsentry },
associated_app_name: 'editor',
onRemovedFromShared,
});
await items.find((item) => item.html === 'share_remove_from_shared').onClick();
expect(puter.fs.unshare).toHaveBeenCalledWith('/ann/uid-1/notes.txt', 'me');
expect(onRemovedFromShared).toHaveBeenCalledTimes(1);
return row;
};
afterEach(() => {
document.body.innerHTML = '';
});
it('takes the row away once the user has left it', async () => {
expect((await remove({})).isConnected).toBe(false);
});
it('keeps the row for the listing to update when a team still shares it', async () => {
expect((await remove({ also_team_share: true })).isConnected).toBe(true);
});
});
describe('Open With', () => {
const editor = { name: 'editor', title: 'Editor', uuid: 'app-editor' };
const viewer = { name: 'viewer', title: 'Viewer', uuid: 'app-viewer' };
+41 -4
View File
@@ -17,13 +17,50 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { strongerMode } from './shareModes.js';
// Largest page the backend will serve (ShareStore.MAX_HOLDER_PAGE_SIZE).
const PAGE_SIZE = 200;
/**
* Every share the current user holds, across all pages. A page can be short
* once unreachable items are filtered out, so it pages on `cursor` rather than
* on the item count.
* One row per shared item. The API lists a row per grant and grants add up, so
* an item granted twice keeps its strongest row. It stays a team share only
* when no grant names the user directly, since a direct grant is theirs to leave.
* A merged row lists everyone who granted it in `issuers`, strongest grant first,
* and sets `alsoHeldByTeam` when leaving the direct grant leaves a team's in place.
*
* @param {Array<Object>} shares
* @returns {Array<Object>}
*/
const issuersOf = (share) => share.issuers ?? [share.issuer];
const heldByTeam = (share) => Boolean(share.holderTeam) || share.alsoHeldByTeam === true;
export const mergeGrants = (shares) => {
const merged = new Map();
for ( const share of shares ) {
const key = share.entryUid ?? share.path;
const held = merged.get(key);
if ( ! held ) {
merged.set(key, share);
continue;
}
const [best, other] = strongerMode(held.mode, share.mode) === held.mode ? [held, share] : [share, held];
const teamOnly = held.holderTeam && share.holderTeam;
const issuers = [...issuersOf(best), ...issuersOf(other)].filter(Boolean);
merged.set(key, {
...best,
holderTeam: teamOnly ? best.holderTeam : undefined,
alsoHeldByTeam: ! teamOnly && (heldByTeam(held) || heldByTeam(share)),
issuers: [...new Set(issuers)],
});
}
return [...merged.values()];
};
/**
* Every item shared with the current user, across all pages. A page can be
* short once unreachable items are filtered out, so it pages on `cursor`
* rather than on the item count.
*
* @returns {Promise<Array<Object>>}
*/
@@ -40,7 +77,7 @@ const list_all_shared = async () => {
cursor = page.cursor;
} while ( cursor );
return shares;
return mergeGrants(shares);
};
export default list_all_shared;
+127
View File
@@ -0,0 +1,127 @@
/*
* 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import list_all_shared, { mergeGrants } from './listAllShared.js';
const BUDGET = '33333333-3333-3333-3333-333333333333';
const REPORT = '44444444-4444-4444-4444-444444444444';
const TEAM = { uid: 'team-1', name: 'Finance', handle: 'finance' };
const grant = (entryUid, name, extra) => ({
entryUid,
path: `/jf/${entryUid}/${name}`,
name,
...extra,
});
describe('mergeGrants', () => {
it('lists an item once, with its strongest grant', () => {
const merged = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'manage', issuer: 'jf' }),
grant(REPORT, 'report.pdf', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'delegate' }),
grant(REPORT, 'report.pdf', { mode: 'write', issuer: 'delegate' }),
]);
expect(merged.map((share) => [share.entryUid, share.mode, share.issuer])).toEqual([
[BUDGET, 'manage', 'jf'],
[REPORT, 'write', 'delegate'],
]);
});
it('names everyone who granted the item, strongest grant first', () => {
const [share] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf', holderTeam: TEAM }),
]);
expect(share.issuer).toBe('delegate');
expect(share.issuers).toEqual(['delegate', 'jf']);
});
it('keeps the first of two equal grants', () => {
const [share] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'delegate' }),
]);
expect(share.issuer).toBe('jf');
});
it('is not a team share when one grant names the user directly', () => {
const [share] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'jf', holderTeam: TEAM }),
]);
expect(share.mode).toBe('write');
expect(share.holderTeam).toBeUndefined();
expect(share.alsoHeldByTeam).toBe(true);
});
it('marks only an item held both ways as also held by a team', () => {
const [direct, team] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
grant(REPORT, 'report.pdf', { mode: 'read', holderTeam: TEAM }),
grant(REPORT, 'report.pdf', { mode: 'write', holderTeam: TEAM }),
]);
expect(direct.alsoHeldByTeam).toBe(false);
expect(team.alsoHeldByTeam).toBe(false);
});
it('still marks it when a later grant is direct again', () => {
const [share] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', holderTeam: TEAM }),
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
]);
expect(share.holderTeam).toBeUndefined();
expect(share.alsoHeldByTeam).toBe(true);
});
it('stays a team share when every grant is to a team', () => {
const [share] = mergeGrants([
grant(BUDGET, 'Budget', { mode: 'read', holderTeam: { ...TEAM, uid: 'team-2' } }),
grant(BUDGET, 'Budget', { mode: 'write', holderTeam: TEAM }),
]);
expect(share.holderTeam).toEqual(TEAM);
});
});
describe('list_all_shared', () => {
beforeEach(() => {
globalThis.window = { puter: { fs: { listShared: vi.fn() } } };
});
it('merges grants for one item that arrive on different pages', async () => {
window.puter.fs.listShared
.mockResolvedValueOnce({
items: [grant(BUDGET, 'Budget', { mode: 'manage' })],
cursor: 'next',
})
.mockResolvedValueOnce({
items: [grant(BUDGET, 'Budget', { mode: 'read' })],
});
const shares = await list_all_shared();
expect(shares).toHaveLength(1);
expect(shares[0].mode).toBe('manage');
expect(window.puter.fs.listShared).toHaveBeenLastCalledWith({ limit: 200, cursor: 'next' });
});
});
@@ -159,6 +159,7 @@ const refresh_item_container = function (el_item_container, options) {
shared_by: share.issuer,
owner: share.owner,
holder_team: share.holderTeam,
also_team_share: share.alsoHeldByTeam,
metadata: '',
})))
: puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' });
@@ -264,6 +265,7 @@ const refresh_item_container = function (el_item_container, options) {
shared_by: fsentry.shared_by,
is_shared: fsentry.is_shared,
is_team_share: !!fsentry.holder_team,
is_also_team_share: fsentry.also_team_share === true,
owner: fsentry.owner?.username ?? fsentry.owner,
});
}
@@ -107,6 +107,31 @@ describe('refresh_item_container in the Shared view', () => {
is_team_share: true,
});
});
it('flags a direct share the team holds too', async () => {
list_all_shared.mockResolvedValue([
{
entryUid: 'u-1',
name: 'a.txt',
path: '/bob/u-1/a.txt',
isDir: false,
mode: 'read',
issuer: 'bob',
owner: 'bob',
alsoHeldByTeam: true,
},
]);
document.body.innerHTML =
'<div class="window"><div class="item-container" data-path="/alice/Shared"></div></div>';
refresh_item_container($('.item-container'));
await vi.waitFor(() => expect(UIItem).toHaveBeenCalledTimes(1));
expect(UIItem.mock.calls[0][0]).toMatchObject({
is_team_share: false,
is_also_team_share: true,
});
});
});
const deferred = () => {
+14
View File
@@ -23,6 +23,20 @@
// place in these dialogs; a row already set to one is shown as-is.
export const MODES = ['read', 'write', 'manage'];
// Every mode the API grants, weakest first. Each one implies those before it.
const STRENGTH = ['see', 'list', 'read', 'write', 'manage'];
/**
* The stronger of two access modes. `held` wins a tie, and a mode outside the
* API's five never beats a known one.
*
* @param {string} held
* @param {string} other
* @returns {string}
*/
export const strongerMode = (held, other) =>
STRENGTH.indexOf(other) > STRENGTH.indexOf(held) ? other : held;
/**
* Human-readable label for an access mode, ready to drop into HTML.
*
+17 -2
View File
@@ -6,12 +6,12 @@ import { decode, encode } from 'html-entities';
globalThis.window = globalThis.window ?? {};
globalThis.html_encode = (str) => encode(str);
let MODES, mode_label, options_for;
let MODES, mode_label, options_for, strongerMode;
beforeAll(async () => {
await import('../i18n/i18n.js'); // installs window.i18n
globalThis.i18n = window.i18n;
({ MODES, mode_label, options_for } = await import('./shareModes.js'));
({ MODES, mode_label, options_for, strongerMode } = await import('./shareModes.js'));
});
// What the browser renders for a fragment of HTML-safe markup.
@@ -41,6 +41,21 @@ describe('mode_label', () => {
});
});
describe('strongerMode', () => {
it('ranks all five API modes', () => {
const modes = ['see', 'list', 'read', 'write', 'manage'];
for ( let i = 1; i < modes.length; i++ ) {
expect(strongerMode(modes[i - 1], modes[i])).toBe(modes[i]);
expect(strongerMode(modes[i], modes[i - 1])).toBe(modes[i]);
}
});
it('never lets an unrecognized mode beat a known one', () => {
expect(strongerMode('see', 'owner')).toBe('see');
expect(strongerMode('owner', 'see')).toBe('see');
});
});
describe('options_for', () => {
const values = (html) => [...html.matchAll(/value="([^"]*)"/g)].map((m) => m[1]);
+6 -2
View File
@@ -19,6 +19,7 @@
import list_all_shared from './listAllShared.js';
import { is_owned_by_me } from './pathOwner.js';
import { strongerMode } from './shareModes.js';
import { is_share_root } from './sharePaths.js';
// What we hold on each shared root, by path: `{ mode, name }`. A `readdir`
@@ -29,13 +30,16 @@ let inflight = null;
/**
* Record what a Shared listing reported. Replaces rather than merges, so a
* share withdrawn elsewhere does not linger.
* share withdrawn elsewhere does not linger. Grants add up, so a path listed
* more than once keeps its strongest mode.
*/
export const remember_shared_roots = (shares) => {
roots.clear();
for ( const share of shares ) {
if ( ! share?.path || ! share?.mode ) continue;
roots.set(share.path, { mode: share.mode, name: share.name });
const held = roots.get(share.path);
const mode = held ? strongerMode(held.mode, share.mode) : share.mode;
roots.set(share.path, { mode, name: share.name });
}
loaded = true;
};
+13
View File
@@ -64,6 +64,19 @@ describe('shared_access', () => {
);
});
it('keeps the strongest mode when a path is granted more than once', async () => {
remember_shared_roots([
{ path: `/jf/${BUDGET}/Budget`, mode: 'manage' },
{ path: `/jf/${BUDGET}/Budget`, mode: 'read' },
{ path: `/jf/${REPORT}/report.pdf`, mode: 'read' },
{ path: `/jf/${REPORT}/report.pdf`, mode: 'write' },
]);
expect(await shared_mode_for(`/jf/${BUDGET}/Budget`)).toBe('manage');
expect(await shared_mode_for(`/jf/${REPORT}/report.pdf`)).toBe('write');
expect(await can_share(`/jf/${BUDGET}/Budget/q1.xlsx`)).toBe(true);
expect(await can_rename(`/jf/${REPORT}/report.pdf`)).toBe(true);
});
it('reports nothing outside every shared root', async () => {
expect(await shared_mode_for(`/jf/${CONTENTS}/Private/a.txt`)).toBe(null);
});