` stands in for wherever the owner keeps the item. Pass that path back to any `puter.fs` method and it resolves normally; what it does not tell you is the folder the item lives in, or what sits beside it. Your own items are never listed here.
+
+## Examples
+
+List everything shared with you
+
+```html;fs-listShared
+
+
+
+
+
+
+```
+
+Page through every share
+
+```js
+let cursor;
+const all = [];
+do {
+ const page = await puter.fs.listShared({ limit: 50, cursor });
+ all.push(...page.items);
+ cursor = page.cursor;
+} while (cursor);
+```
+
+Open a file someone shared with you
+
+```js
+const page = await puter.fs.listShared();
+const shared = page.items.find((item) => !item.isDir);
+if (shared) {
+ const blob = await puter.fs.read(shared.path);
+ puter.print(await blob.text());
+}
+```
+
+## Related
+
+- [`puter.fs.share()`](/FS/share/) - Grant access
+- [`puter.fs.getShares()`](/FS/getShares/) - See who can reach an item you manage
diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md
new file mode 100644
index 0000000000..e16709efe7
--- /dev/null
+++ b/src/docs/src/FS/share.md
@@ -0,0 +1,127 @@
+---
+title: puter.fs.share()
+description: Give another Puter user access to a file or directory.
+platforms: [websites, apps, nodejs, workers]
+---
+
+This method gives another Puter user access to a file or directory you own, or one you have been given `manage` access to.
+
+> **What an app can share.** An app never gets more reach than it was given. It
+> can share its own AppData, and files the user specifically granted it, at up
+> to the level of access it holds itself — so an app with read access can grant
+> read, and nothing more. Files its user owns but never handed to the app stay
+> out of reach, and `listShared()` shows an app only the shares it can reach.
+> Shares an app creates are attributed to the user and carry `issuedByApp`, so
+> the owner can tell them apart in [`getShares()`](/FS/getShares/).
+
+## Syntax
+
+```js
+puter.fs.share(path, recipient)
+puter.fs.share(path, recipient, mode)
+puter.fs.share(options)
+```
+
+## Parameters
+
+#### `path` (String) (required)
+
+The path to the file or directory to share.
+If `path` is not absolute, it will be resolved relative to the app's root directory.
+
+#### `recipient` (String | Object | Array) (required)
+
+Who to share with. A string containing `@` is treated as an email address, and any other string as a username. You can also pass `{ email }` or `{ username }`, or an array to share with several people at once.
+
+#### `mode` (String) (optional)
+
+How much access to grant. Defaults to `'read'`.
+
+- `'read'` - Read the item.
+- `'write'` - Read and change the item. Does **not** allow re-sharing it.
+- `'manage'` - Everything `'write'` allows, plus re-sharing the item with other people.
+- `'list'`, `'see'` - Weaker than `read`; useful for making an item discoverable without exposing its contents.
+
+#### `options` (Object) (optional)
+
+An object with the following properties:
+
+- `path` (String) - Item to share. Required when passing options as the only argument.
+- `uid` (String) - Item to share, by UID. Can be used instead of `path`.
+- `paths` (Array) - Several items to share in one call.
+- `recipient` (String | Object | Array) - Who to share with.
+- `mode` (String) - Access to grant. Defaults to `'read'`.
+
+## Return value
+
+A `Promise` that resolves to an array of share objects, one per recipient/item pair that succeeded. Each has:
+
+- `uid` (String) - Identifier for this share.
+- `mode` (String) - Access the recipient now has.
+- `path` (String) - Path of the shared item, masked when you do not own it (see [`listShared()`](/FS/listShared/)).
+- `entryUid` (String) - UID of the shared item.
+- `isDir` (Boolean) - Whether the shared item is a directory.
+- `issuer` (String) - Username of whoever granted the share.
+- `holder` (String) - Username of whoever received it.
+- `inheritedFrom` (String) - Path of the shared ancestor this access comes from, or `null` when the share is on the item itself.
+- `modified` (Number) - Last-modified time of the item, in unix seconds.
+- `size` (Number) - Size of the item in bytes; `null` for a directory.
+
+Sharing the same item with the same person again **replaces** their access rather than adding a second share, so raising someone from `read` to `write` is just another call.
+
+If some recipients succeed and others fail, the promise resolves with the ones that worked. It rejects only when every pair failed.
+
+## Examples
+
+Share a file with another user
+
+```html;fs-share
+
+
+
+
+
+
+```
+
+Let someone edit, and let someone else re-share
+
+```js
+// An editor can change the file but cannot pass it on.
+await puter.fs.share('report.txt', 'editor@example.com', 'write');
+
+// A manager can edit it AND share it with other people.
+await puter.fs.share('report.txt', 'manager@example.com', 'manage');
+```
+
+Share one item with several people
+
+```js
+await puter.fs.share({
+ path: 'report.txt',
+ recipient: ['a@example.com', 'b@example.com'],
+ mode: 'read',
+});
+```
+
+## Live updates
+
+Changes inside a shared item are not pushed to recipients in real time —
+filesystem socket events go to the item's owner only. A client that shows
+shared content and needs it current should re-read it (`readdir`/`stat`)
+when freshness matters, for example on focus or an explicit refresh.
+
+## Related
+
+- [`puter.fs.unshare()`](/FS/unshare/) - Withdraw access
+- [`puter.fs.getShares()`](/FS/getShares/) - See who can reach an item
+- [`puter.fs.listShared()`](/FS/listShared/) - See what others have shared with you
diff --git a/src/docs/src/FS/unshare.md b/src/docs/src/FS/unshare.md
new file mode 100644
index 0000000000..8158473679
--- /dev/null
+++ b/src/docs/src/FS/unshare.md
@@ -0,0 +1,89 @@
+---
+title: puter.fs.unshare()
+description: Withdraw a user's access to a shared file or directory.
+platforms: [websites, apps, nodejs, workers]
+---
+
+This method withdraws a user's access to a file or directory.
+
+> **What an app can share.** An app never gets more reach than it was given. It
+> can share its own AppData, and files the user specifically granted it, at up
+> to the level of access it holds itself — so an app with read access can grant
+> read, and nothing more. Files its user owns but never handed to the app stay
+> out of reach, and `listShared()` shows an app only the shares it can reach.
+> Shares an app creates are attributed to the user and carry `issuedByApp`, so
+> the owner can tell them apart in [`getShares()`](/FS/getShares/).
+
+## Syntax
+
+```js
+puter.fs.unshare(path, recipient)
+puter.fs.unshare(options)
+```
+
+## Parameters
+
+#### `path` (String) (required)
+
+The path to the file or directory. If `path` is not absolute, it will be resolved relative to the app's root directory.
+
+#### `recipient` (String | Object) (required)
+
+Whose access to withdraw. A string containing `@` is treated as an email address, and any other string as a username.
+
+Pass **yourself** to leave a share someone else gave you.
+
+#### `options` (Object) (optional)
+
+An object with the following properties:
+
+- `path` (String) - The item. Required when passing options as the only argument.
+- `uid` (String) - The item, by UID. Can be used instead of `path`.
+- `recipient` (String | Object) - Whose access to withdraw.
+
+## Return value
+
+A `Promise` that resolves to `{ revoked }`, where `revoked` is how many grants were actually removed. It is `0` when there was nothing to withdraw, which is not an error.
+
+## Who can withdraw what
+
+- The item's **owner** can withdraw any share of it, whoever granted it.
+- Anyone else can withdraw the shares **they** granted.
+- **Anyone** can withdraw their own access, whoever granted it.
+
+An item's owner cannot be removed from their own item.
+
+Withdrawing someone's access also withdraws whatever **they** re-shared of that item. Their authority to grant came from the access being removed, so it cannot outlive it.
+
+## Examples
+
+Stop sharing a file
+
+```html;fs-unshare
+
+
+
+
+
+
+```
+
+Leave a share someone gave you
+
+```js
+const me = await puter.auth.getUser();
+await puter.fs.unshare('/alice/report.txt', me.username);
+```
+
+## Related
+
+- [`puter.fs.share()`](/FS/share/) - Grant access
+- [`puter.fs.getShares()`](/FS/getShares/) - See who can reach an item
diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js
index 123dbf2432..3bdad34b86 100755
--- a/src/docs/src/sidebar.js
+++ b/src/docs/src/sidebar.js
@@ -361,6 +361,38 @@ let sidebar = [
source: '/FS/upload.md',
path: '/FS/upload',
},
+ {
+ title: 'share()',
+ page_title: 'puter.fs.share()',
+ title_tag: 'puter.fs.share()',
+ icon: '/assets/img/function.svg',
+ source: '/FS/share.md',
+ path: '/FS/share',
+ },
+ {
+ title: 'unshare()',
+ page_title: 'puter.fs.unshare()',
+ title_tag: 'puter.fs.unshare()',
+ icon: '/assets/img/function.svg',
+ source: '/FS/unshare.md',
+ path: '/FS/unshare',
+ },
+ {
+ title: 'listShared()',
+ page_title: 'puter.fs.listShared()',
+ title_tag: 'puter.fs.listShared()',
+ icon: '/assets/img/function.svg',
+ source: '/FS/listShared.md',
+ path: '/FS/listShared',
+ },
+ {
+ title: 'getShares()',
+ page_title: 'puter.fs.getShares()',
+ title_tag: 'puter.fs.getShares()',
+ icon: '/assets/img/function.svg',
+ source: '/FS/getShares.md',
+ path: '/FS/getShares',
+ },
],
},
{
diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js
index dc569ae6d2..07c9ff0162 100644
--- a/src/gui/src/UI/Dashboard/TabFiles.js
+++ b/src/gui/src/UI/Dashboard/TabFiles.js
@@ -36,28 +36,10 @@ import UIItemPropertiesModal from './UIItemPropertiesModal.js';
import { dedupedName } from './dedupedName.js';
import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js';
-const icons = {
- document: ``,
- files: ``,
- folder: ``,
- more: ``,
- // Header action icons use the Material Symbols wght300 cut (one step
- // lighter than the default 400) to match the thinned nav arrows.
- newFolder: ``,
- upload: ``,
- trash: ``,
- download: ``,
- cut: ``,
- copy: ``,
- restore: ``,
- list: ``,
- grid: ``,
- gridSmall: ``,
- sort: ``,
- select: ``,
- done: ``,
- worker: ``,
-};
+import { icons } from '../../helpers/actionIcons.js';
+import list_all_shared from '../../helpers/list_all_shared.js';
+import { remember_shared_roots } from '../../helpers/shared_access.js';
+import { parent_path_for, shared_crumbs_for } from '../../helpers/share_paths.js';
const { html_encode, SelectionArea } = window;
@@ -99,6 +81,7 @@ const TabFiles = {
Pictures
Public
Videos
+
${i18n('shared')}
Trash
@@ -705,12 +688,9 @@ const TabFiles = {
if ( $selectedRow.length > 0 ) {
e.preventDefault();
e.stopPropagation();
- const $nameEditor = $selectedRow.find('.item-name-editor');
- const $itemName = $selectedRow.find('.item-name');
- if ( $nameEditor.length > 0 ) {
- $itemName.hide();
- $nameEditor.show().addClass('item-name-editor-active').focus().select();
- }
+ // The shared editor carries the guards (immutable, trash,
+ // items you hold no write on) this handler used to skip.
+ window.activate_item_name_editor($selectedRow[0]);
}
return false;
}
@@ -1217,9 +1197,11 @@ const TabFiles = {
// Up button
$(el_window_navbar_up_btn).on('click', function () {
- if ( _this.currentPath === '/' ) return;
+ if ( _this.currentPath === '/' || _this.currentPath === window.shared_path ) return;
- const target_path = path.resolve(path.join(_this.currentPath, '..'));
+ // Above a shared item is its owner's folder, which is not ours to
+ // open — `parent_path_for` sends us to Shared instead.
+ const target_path = parent_path_for(path.resolve(_this.currentPath));
_this.pushNavHistory(target_path);
_this.renderDirectory(target_path);
});
@@ -1258,8 +1240,8 @@ const TabFiles = {
});
makeNavBtnSpringLoaded(el_window_navbar_up_btn, () => {
- if ( _this.currentPath === '/' ) return false;
- const target_path = path.resolve(path.join(_this.currentPath, '..'));
+ if ( _this.currentPath === '/' || _this.currentPath === window.shared_path ) return false;
+ const target_path = parent_path_for(path.resolve(_this.currentPath));
if ( ! _this.canSpringLoadInto(target_path) ) return false;
_this.pushNavHistory(target_path);
_this.renderDirectory(target_path);
@@ -1267,6 +1249,8 @@ const TabFiles = {
// New folder button
document.querySelector('.new-folder-btn').onclick = () => {
+ // The Shared view is a query, not a directory.
+ if ( _this.currentPath === window.shared_path ) return;
_this.createFolderInstant(_this.currentPath);
};
@@ -1274,6 +1258,7 @@ const TabFiles = {
fileInput.onchange = async (e) => {
const files = e.target.files;
if ( !files || files.length === 0 ) return;
+ if ( _this.currentPath === window.shared_path ) return;
let upload_progress_window;
let opid;
@@ -2162,9 +2147,32 @@ const TabFiles = {
const readdirArg = isPath
? { path: target, consistency: options.consistency || 'eventual' }
: { uid: target, consistency: options.consistency || 'eventual' };
+ // Shared is a query, not a directory — its rows come from listShared
+ // and live under their owners' paths.
+ const isSharedView = target === window.shared_path;
let directoryContents;
try {
- directoryContents = await window.puter.fs.readdir(readdirArg);
+ directoryContents = isSharedView
+ ? (await list_all_shared().then((shares) => {
+ remember_shared_roots(shares);
+ return shares;
+ })).map((share) => ({
+ uid: share.entryUid,
+ name: share.name ?? share.path.split('/').pop(),
+ path: share.path,
+ is_dir: share.isDir,
+ // A share row has no fsentry behind it to stat, so the
+ // listing carries what the icon needs.
+ type: share.type,
+ thumbnail: share.thumbnail,
+ modified: share.modified,
+ size: share.size,
+ shared_with_me: true,
+ share_mode: share.mode,
+ shared_by: share.issuer,
+ owner: share.owner,
+ }))
+ : await window.puter.fs.readdir(readdirArg);
} catch ( err ) {
// readdir rejects on any backend error (permission, deleted dir,
// network). Without this, renderingDirectory would stay true and
@@ -2412,6 +2420,9 @@ const TabFiles = {
row.setAttribute("data-uid", file.uid);
row.setAttribute("data-is_dir", file.is_dir ? "1" : "0");
row.setAttribute("data-is_trash", file.is_trash ? "1" : "0");
+ row.setAttribute("data-shared_with_me", file.shared_with_me ? "1" : "0");
+ row.setAttribute("data-share_mode", file.share_mode ?? '');
+ row.setAttribute("data-shared_by", file.shared_by ?? '');
row.setAttribute("data-has_website", file.has_website ? "1" : "0");
// setAttribute stores values literally (no HTML parsing), so values must
// stay raw — encoding here would leave e.g. `&` inside data-path and
@@ -3736,7 +3747,8 @@ const TabFiles = {
forwardBtn.removeClass('path-btn-disabled');
}
- if ( this.currentPath === '/' ) {
+ // The Shared view has no parent either — it is a query, not a directory.
+ if ( this.currentPath === '/' || this.currentPath === window.shared_path ) {
upBtn.addClass('path-btn-disabled');
} else {
upBtn.removeClass('path-btn-disabled');
@@ -4004,11 +4016,14 @@ const TabFiles = {
const isTrashFolder = targetPath === window.trash_path;
const isTrashedPath = targetPath.startsWith(`${window.trash_path}/`);
+ // The Shared view is a query, not a directory — nothing can be
+ // created, pasted or uploaded "into" it.
+ const isSharedView = targetPath === window.shared_path;
const items = [];
// New submenu (folder, text document, etc.) - not available in Trash
// We create a custom "New" submenu to handle folder creation with refresh and rename activation
- if ( ! isTrashFolder ) {
+ if ( ! isTrashFolder && ! isSharedView ) {
const newMenuItems = new_context_menu_item(targetPath, null);
// Override the "New Folder" onClick to refresh and activate rename
@@ -4099,7 +4114,7 @@ const TabFiles = {
}
// Paste - only if clipboard has items and not in Trash
- if ( !isTrashFolder && window.clipboard && window.clipboard.length > 0 ) {
+ if ( !isTrashFolder && !isSharedView && window.clipboard && window.clipboard.length > 0 ) {
items.push({
html: i18n('paste'),
onClick: async function () {
@@ -4139,7 +4154,7 @@ const TabFiles = {
}
// Upload Here - not available in Trash
- if ( ! isTrashFolder ) {
+ if ( ! isTrashFolder && ! isSharedView ) {
items.push({
html: i18n('upload'),
onClick: function () {
@@ -4471,8 +4486,10 @@ const TabFiles = {
return;
}
- // Block uploads to trash
- if ( _this.currentPath === window.trash_path ) {
+ // Block uploads to trash, and to the Shared view — a query,
+ // not a directory.
+ if ( _this.currentPath === window.trash_path ||
+ _this.currentPath === window.shared_path ) {
return;
}
@@ -4623,6 +4640,22 @@ const TabFiles = {
const dirs = (abs_path === '/' ? [''] : abs_path.split('/'));
const dirpaths = (abs_path === '/' ? ['/'] : []);
const path_seperator_html = `
`;
+
+ // The Shared view is a query, not a directory — one crumb, no ancestry.
+ if ( abs_path === window.shared_path ) {
+ return `${path_seperator_html}${html_encode(i18n('shared'))}`;
+ }
+
+ // Someone else's tree is shown from the share down, not from their home.
+ const shared = shared_crumbs_for(abs_path);
+ if ( shared ) {
+ let str = `${path_seperator_html}${html_encode(i18n('shared'))}`;
+ for ( const crumb of shared ) {
+ str += `${path_seperator_html}${html_encode(crumb.label)}`;
+ }
+ return str;
+ }
+
if ( dirs.length > 1 ) {
for ( let i = 0; i < dirs.length; i++ ) {
dirpaths[i] = '';
diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js
index 9919100a32..033dc090c2 100644
--- a/src/gui/src/UI/UIItem.js
+++ b/src/gui/src/UI/UIItem.js
@@ -24,6 +24,7 @@ import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequir
import UIContextMenu from './UIContextMenu.js';
import UIAlert from './UIAlert.js';
import UIWindowPublishWorker from './UIWindowPublishWorker.js';
+import UIWindowShare from './UIWindowShare.js';
import path from '../lib/path.js';
import truncate_filename from '../helpers/truncate_filename.js';
import launch_app from '../helpers/launch_app.js';
@@ -31,6 +32,8 @@ import open_item from '../helpers/open_item.js';
import publish_as_website from '../helpers/publish_as_website.js';
import mime from '../lib/mime.js';
import { isWeblinkName, weblinkChangeIconMenuItem } from '../helpers/weblink.js';
+import { is_owned_by_me } from '../helpers/path_owner.js';
+import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from '../helpers/shared_access.js';
const AI_APP_NAME = 'ai';
@@ -129,6 +132,10 @@ async function UIItem (options) {
options.is_selected = options.is_selected ?? false;
options.is_shortcut = options.is_shortcut ?? 0;
options.is_trash = options.is_trash ?? false;
+ options.shared_with_me = options.shared_with_me ?? false;
+ options.share_mode = options.share_mode ?? '';
+ options.shared_by = options.shared_by ?? '';
+ options.owner = options.owner ?? '';
options.metadata = options.metadata ?? '';
options.multiselectable = (options.multiselectable === undefined || options.multiselectable === true) ? true : false;
options.shortcut_to = options.shortcut_to ?? '';
@@ -161,6 +168,10 @@ async function UIItem (options) {
data-uid="${options.uid}"
data-is_dir="${options.is_dir ? 1 : 0}"
data-is_trash="${options.is_trash ? 1 : 0}"
+ data-shared_with_me="${options.shared_with_me ? 1 : 0}"
+ data-share_mode="${html_encode(options.share_mode)}"
+ data-shared_by="${html_encode(options.shared_by)}"
+ data-owner="${html_encode(options.owner)}"
data-has_website="${show_website_badge ? 1 : 0 }"
data-website_url = "${website_url ? html_encode(website_url) : ''}"
data-immutable="${options.immutable}"
@@ -1134,6 +1145,26 @@ async function UIItem (options) {
// -------------------------------------------------------
else {
const is_trash = $(el_item).attr('data-path') === window.trash_path || $(el_item).attr('data-shortcut_to_path') === window.trash_path;
+ // Has its own share, so it is a row the Shared view listed.
+ const is_shared_root = $(el_item).attr('data-shared_with_me') === '1';
+ // Someone else's, however we got here — including items reached by
+ // opening a shared folder, which carry no share markers.
+ const is_not_mine = !is_owned_by_me($(el_item).attr('data-path'));
+ // `manage` inherits downwards, so a file inside a folder you manage
+ // counts too — the row itself only carries a mode at a shared root.
+ const can_manage_share =
+ $(el_item).attr('data-share_mode') === 'manage'
+ || (await shared_mode_for($(el_item).attr('data-path'))) === 'manage';
+ // Moving and deleting go by the holding folder, not by the item.
+ const may_restructure = !is_not_mine
+ || await can_restructure($(el_item).attr('data-path'));
+ // A shared FILE you hold write on is renameable even though it
+ // can't be moved; a shared folder root is not.
+ const may_rename = !is_not_mine
+ || await can_rename(
+ $(el_item).attr('data-path'),
+ ['1', 'true'].includes($(el_item).attr('data-is_dir')),
+ );
const is_shortcut = !! $(el_item).attr('data-shortcut_to_path');
const is_weblink = isWeblinkName($(el_item).attr('data-name'));
menu_items = [];
@@ -1555,9 +1586,48 @@ async function UIItem (options) {
menu_items.push(weblinkChangeIconMenuItem(el_item));
}
// -------------------------------------------
+ // Share
+ // -------------------------------------------
+ if ( !is_trash && !is_trashed && (!is_not_mine || can_manage_share) ) {
+ menu_items.push({
+ html: i18n('share_ellipsis'),
+ onClick: async function () {
+ UIWindowShare({
+ path: $(el_item).attr('data-path'),
+ name: $(el_item).attr('data-name'),
+ });
+ },
+ });
+ }
+ // -------------------------------------------
+ // Remove from Shared
+ // -------------------------------------------
+ // Someone else owns this, so deleting it would move their file into
+ // our trash — which the backend refuses. Give up our own access
+ // instead, which is what "remove it from my view" actually means.
+ if ( is_shared_root ) {
+ menu_items.push({
+ html: i18n('share_remove_from_shared'),
+ onClick: async function () {
+ try {
+ await puter.fs.unshare(
+ $(el_item).attr('data-path'),
+ window.user.username,
+ );
+ // Or mode lookups keep answering for a share we
+ // just walked away from.
+ invalidate_shared_roots();
+ $(el_item).removeItems();
+ } catch (e) {
+ UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
+ }
+ },
+ });
+ }
+ // -------------------------------------------
// Delete
// -------------------------------------------
- if ( $(el_item).attr('data-immutable') === '0' && !is_trashed ) {
+ if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && may_restructure ) {
menu_items.push({
html: i18n('delete'),
onClick: async function () {
@@ -1597,7 +1667,7 @@ async function UIItem (options) {
// -------------------------------------------
// Rename
// -------------------------------------------
- if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && !is_trash ) {
+ if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && !is_trash && may_rename ) {
menu_items.push({
html: i18n('rename'),
onClick: function () {
@@ -1856,7 +1926,7 @@ $.fn.removeItems = async function (options) {
return this;
};
-window.activate_item_name_editor = function (el_item) {
+window.activate_item_name_editor = async function (el_item) {
// files in trash cannot be renamed, the user should be notified with an Alert.
if ( $(el_item).attr('data-immutable') !== '0' ) {
return;
@@ -1866,6 +1936,15 @@ window.activate_item_name_editor = function (el_item) {
UIAlert(i18n('items_in_trash_cannot_be_renamed'));
return;
}
+ // Someone else's item is renameable only with write on it (files, not
+ // shared folder roots) — this also covers the click-to-edit and keyboard
+ // paths, not just the context menu.
+ else if ( ! await can_rename(
+ $(el_item).attr('data-path'),
+ ['1', 'true'].includes($(el_item).attr('data-is_dir')),
+ ) ) {
+ return;
+ }
const el_item_name = $(el_item).find('.item-name');
const el_item_name_editor = $(el_item).find('.item-name-editor').get(0);
diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js
index 713d9e2b80..e0a466c226 100644
--- a/src/gui/src/UI/UIWindow.js
+++ b/src/gui/src/UI/UIWindow.js
@@ -30,6 +30,8 @@ import launch_app from '../helpers/launch_app.js';
import publish_as_website from '../helpers/publish_as_website.js';
import item_icon from '../helpers/item_icon.js';
+import { parent_path_for, shared_crumbs_for } from '../helpers/share_paths.js';
+import { has_shared_roots } from '../helpers/shared_access.js';
import { is_window_hidden, is_unseen_background_window, user_facing_windows } from '../helpers/window_visibility.js';
const el_body = document.getElementsByTagName('body')[0];
@@ -363,6 +365,7 @@ async function UIWindow (options) {
h += ``;
h += ``;
h += ``;
+ h += ``;
} else {
let items = JSON.parse(window.sidebar_items);
// Saved sidebar orders may predate the Home entry — make sure it's always present
@@ -395,6 +398,10 @@ async function UIWindow (options) {
{
icon = window.icons['sidebar-folder-videos.svg'];
}
+ else if ( item.path === window.shared_path )
+ {
+ icon = window.icons['sidebar-folder-shared.svg'];
+ }
else
{
icon = window.icons['sidebar-folder.svg'];
@@ -417,7 +424,7 @@ async function UIWindow (options) {
// Forward
h += `
`;
// Up
- h += `
`;
+ h += `
`;
h += '';
// Path
h += `${window.navbar_path(options.path, window.user.username)}
`;
@@ -1076,6 +1083,18 @@ async function UIWindow (options) {
if ( options.is_dir ) {
window.navbar_path_droppable(el_window);
window.sidebar_item_droppable(el_window);
+
+ // Saved sidebar orders predate the Shared entry, and unlike Home it
+ // only matters to users who actually have shares — so append it once
+ // that's known, rather than backfilling it for everyone.
+ if ( window.sidebar_items && !JSON.parse(window.sidebar_items).some(item => item.path === window.shared_path) ) {
+ has_shared_roots().then((has_shares) => {
+ const el_sidebar = $(el_window).find('.window-sidebar');
+ if ( ! has_shares || el_sidebar.length === 0 ) return;
+ if ( el_sidebar.find(`.window-sidebar-item[data-path="${html_encode(window.shared_path)}"]`).length > 0 ) return;
+ el_sidebar.append(``);
+ });
+ }
// --------------------------------------------------------
// Back button
// --------------------------------------------------------
@@ -1224,7 +1243,13 @@ async function UIWindow (options) {
// Up button
// --------------------------------------------------------
$(el_window_navbar_up_btn).on('click', function (e) {
- const target_path = path.resolve(path.join($(el_window).attr('data-path'), '..'));
+ // The Shared view has no parent — and `path.resolve` would mangle
+ // its `puter://` form into a navigable-looking garbage path.
+ const current_path = $(el_window).attr('data-path');
+ if ( current_path === window.shared_path ) return;
+ // Above a shared item is its owner's folder, which is not ours to
+ // open — `parent_path_for` sends us to Shared instead.
+ const target_path = parent_path_for(path.resolve(current_path));
// if ctrl/cmd are pressed, open in new window
if ( e.ctrlKey || e.metaKey && (target_path !== undefined && target_path !== null) ) {
UIWindow({
@@ -1766,7 +1791,8 @@ async function UIWindow (options) {
},
drop: function (dragsterEvent, event) {
const e = event.originalEvent;
- if ( options.is_dir ) {
+ // The Shared view is a query, not a directory — nowhere to upload.
+ if ( options.is_dir && $(el_window).attr('data-path') !== window.shared_path ) {
// if files were dropped...
if ( e.dataTransfer?.items?.length > 0 ) {
window.upload_items(e.dataTransfer.items, $(el_window).attr('data-path'));
@@ -2532,7 +2558,9 @@ async function UIWindow (options) {
},
});
- if ( $(el_window).attr('data-path') !== '/' ) {
+ // The Shared view is a query, not a directory — nothing can
+ // be created or pasted "into" it.
+ if ( $(el_window).attr('data-path') !== '/' && $(el_window).attr('data-path') !== window.shared_path ) {
// -------------------------------------------
// -
// -------------------------------------------
@@ -3194,6 +3222,10 @@ window.navbar_path_droppable = (el_window) => {
if ( $(window.mouseover_window).attr('data-id') !== $(el_window).attr('data-id') ) {
return;
}
+ // The Shared view is a query, not a directory — not a drop target.
+ if ( $(this).attr('data-path') === window.shared_path ) {
+ return;
+ }
const items_to_move = [];
// first item
@@ -3291,6 +3323,22 @@ window.navbar_path = (abs_path) => {
const dirs = (abs_path === '/' ? [''] : abs_path.split('/'));
const dirpaths = (abs_path === '/' ? ['/'] : []);
const path_seperator_html = `
`;
+
+ // The Shared view is a query, not a directory — one crumb, no ancestry.
+ if ( abs_path === window.shared_path ) {
+ return `${path_seperator_html}${html_encode(i18n('shared'))}`;
+ }
+
+ // Someone else's tree is shown from the share down, not from their home.
+ const shared = shared_crumbs_for(abs_path);
+ if ( shared ) {
+ let str = `${path_seperator_html}${html_encode(i18n('shared'))}`;
+ for ( const crumb of shared ) {
+ str += `${path_seperator_html}${html_encode(crumb.label)}`;
+ }
+ return str;
+ }
+
if ( dirs.length > 1 ) {
for ( let i = 0; i < dirs.length; i++ ) {
dirpaths[i] = '';
@@ -3348,7 +3396,7 @@ window.update_window_path = async function (el_window, target_path) {
}
// disabled Up button if this is root
- if ( target_path === '/' )
+ if ( target_path === '/' || target_path === window.shared_path )
{
$(el_window_navbar_up_btn).addClass('window-navbar-btn-disabled');
}
@@ -3407,7 +3455,14 @@ window.update_window_path = async function (el_window, target_path) {
$(el_window).attr('data-name', html_encode(path.basename(target_path)));
// /stat
- if ( target_path !== '/' ) {
+ if ( target_path === window.shared_path ) {
+ // A query, not a directory — nothing to stat.
+ $(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
+ $(el_window).attr('data-uid', 'null');
+ $(el_window).find('.window-head-title').text(i18n('shared_with_me'));
+ $(el_window).find('.window-head-icon').attr('src', window.icons['shared.svg']);
+ }
+ else if ( target_path !== '/' ) {
try {
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
@@ -3500,6 +3555,11 @@ window.sidebar_item_droppable = (el_window) => {
if ( $(window.mouseover_window).attr('data-id') !== $(el_window).attr('data-id') ) {
return;
}
+ // The Shared view is a query, not a directory — not a drop target.
+ if ( $(this).attr('data-path') === window.shared_path ) {
+ $(this).removeClass('window-sidebar-item-drag-active');
+ return;
+ }
const items_to_move = [];
// first item
diff --git a/src/gui/src/UI/UIWindowShare.js b/src/gui/src/UI/UIWindowShare.js
new file mode 100644
index 0000000000..2b58f3c3dc
--- /dev/null
+++ b/src/gui/src/UI/UIWindowShare.js
@@ -0,0 +1,239 @@
+/*
+ * 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 UIWindow from './UIWindow.js';
+import UIAlert from './UIAlert.js';
+import path from '../lib/path.js';
+import { owner_of_path } from '../helpers/path_owner.js';
+import { invalidate_shared_roots } from '../helpers/shared_access.js';
+import { icons } from '../helpers/actionIcons.js';
+
+// Offered when granting. The API accepts `see` and `list` too, but they are a
+// developer-level distinction with no place in this dialog — a row already set
+// to one is shown as-is rather than quietly rounded up to `read`.
+const MODES = ['read', 'write', 'manage'];
+
+const mode_label = (mode) => {
+ if ( mode === 'write' ) return i18n('share_access_write');
+ if ( mode === 'manage' ) return i18n('share_access_manage');
+ if ( mode === 'read' ) return i18n('share_access_read');
+ return mode;
+};
+
+const options_for = (current) => {
+ const modes = MODES.includes(current) ? MODES : [current, ...MODES];
+ return modes
+ .map(
+ (mode) =>
+ ``,
+ )
+ .join('');
+};
+
+/**
+ * Sharing dialog for one file or directory.
+ *
+ * @param {object} options
+ * @param {string} options.path Item to share.
+ * @param {string} [options.name] Display name; defaults to the path's basename.
+ * @param {string} [options.owner] Owner's username; defaults to the first path
+ * segment, which is not the current user when a `manage` recipient opens this.
+ */
+async function UIWindowShare (options) {
+ options = options ?? {};
+ const item_path = options.path;
+ const item_name = options.name ?? path.basename(item_path);
+ const item_owner =
+ options.owner ?? owner_of_path(item_path) ?? window.user.username;
+
+ let h = '';
+ h += '';
+ h += '
';
+ h += '
';
+
+ h += `
`;
+ h += '
';
+ h += ``;
+ h += ``;
+ h += '
';
+ h += `
`;
+
+ h += `
${i18n('share_who_has_access')}
`;
+ h += '
';
+ h += '
';
+
+ // One dialog per item — window-level single_instance would refocus a
+ // dialog still bound to a different file.
+ const $existing = $('.window[data-app="share"]').filter(
+ (_, el) => $(el).attr('data-share-path') === item_path,
+ );
+ if ( $existing.length ) {
+ $existing.focusWindow();
+ return;
+ }
+
+ const el_window = await UIWindow({
+ title: `${i18n('share')} — ${item_name}`,
+ app: 'share',
+ icon: window.icons['share-outline.svg'],
+ uid: null,
+ is_dir: false,
+ body_content: h,
+ has_head: true,
+ selectable_body: false,
+ draggable_body: false,
+ allow_context_menu: false,
+ is_resizable: false,
+ is_droppable: false,
+ init_center: true,
+ allow_native_ctxmenu: false,
+ allow_user_select: false,
+ width: 420,
+ height: 'auto',
+ dominant: true,
+ show_in_taskbar: false,
+ onAppend: function (this_window) {
+ $(this_window).find('.share-recipient').get(0)?.focus({ preventScroll: true });
+ },
+ window_class: 'window-share',
+ window_css: { height: 'initial' },
+ body_css: { width: 'initial', padding: '0', 'background-color': 'rgb(245 247 249)' },
+ });
+ $(el_window).attr('data-share-path', item_path);
+
+ const $error = $(el_window).find('.form-error-msg');
+ const $success = $(el_window).find('.form-success-msg');
+ const $list = $(el_window).find('.share-list');
+
+ const show_error = (message) => {
+ $success.hide();
+ $error.html(html_encode(message)).show();
+ };
+
+ const show_success = (message) => {
+ $error.hide();
+ $success.html(message).show();
+ };
+
+ const render = (shares) => {
+ let rows = '';
+ // The owner's access comes from owning the item, so it can't be revoked
+ rows += '';
+ rows += `${html_encode(item_owner)}${item_owner === window.user.username ? ` (${i18n('share_you')})` : ''}`;
+ rows += `${i18n('share_owner')}`;
+ rows += '
';
+
+ for ( const share of shares ) {
+ const holder = html_encode(share.holder ?? '');
+ if ( share.inheritedFrom ) {
+ // Granted on an ancestor, so it can only be changed there
+ rows += '';
+ rows += `${holder}`;
+ rows += `${i18n('share_inherited_via', { folder: path.basename(share.inheritedFrom) })}`;
+ rows += `${html_encode(mode_label(share.mode))}`;
+ rows += '
';
+ continue;
+ }
+ rows += '';
+ rows += `${holder}`;
+ rows += ``;
+ rows += ``;
+ rows += '
';
+ }
+ if ( !shares.length ) {
+ rows += `${i18n('share_no_one')}
`;
+ }
+ $list.html(rows);
+ };
+
+ const refresh = async () => {
+ try {
+ render(await puter.fs.getShares(item_path));
+ } catch (e) {
+ show_error(e?.message ?? i18n('share_failed'));
+ }
+ };
+
+ $(el_window).on('click', '.share-btn', async function () {
+ const recipient = $(el_window).find('.share-recipient').val().trim();
+ if ( !recipient ) return;
+
+ $(this).prop('disabled', true);
+ try {
+ await puter.fs.share({
+ path: item_path,
+ recipient,
+ mode: $(el_window).find('.share-mode').val(),
+ });
+ $(el_window).find('.share-recipient').val('');
+ $error.hide();
+ show_success(i18n('share_shared_with', { recipient: html_encode(recipient) }));
+ invalidate_shared_roots();
+ await refresh();
+ } catch (e) {
+ show_error(e?.message ?? i18n('share_failed'));
+ } finally {
+ $(this).prop('disabled', false);
+ }
+ });
+
+ $(el_window).on('change', '.share-row-mode-select', async function () {
+ const holder = $(this).attr('data-holder');
+ const mode = $(this).val();
+ $(this).prop('disabled', true);
+ try {
+ await puter.fs.share({ path: item_path, recipient: holder, mode });
+ show_success(i18n('share_shared_with', { recipient: html_encode(holder) }));
+ invalidate_shared_roots();
+ await refresh();
+ } catch (e) {
+ show_error(e?.message ?? i18n('share_failed'));
+ invalidate_shared_roots();
+ await refresh();
+ }
+ });
+
+ $(el_window).on('click', '.share-revoke', async function () {
+ const holder = $(this).attr('data-holder');
+ const confirmed = await UIAlert({
+ message: i18n('share_confirm_remove', { recipient: holder }),
+ buttons: [
+ { label: i18n('share_remove'), value: true, type: 'primary' },
+ { label: i18n('cancel'), value: false },
+ ],
+ });
+ if ( ! confirmed ) return;
+ $(this).prop('disabled', true);
+ try {
+ await puter.fs.unshare(item_path, holder);
+ show_success(i18n('share_access_removed', { recipient: html_encode(holder) }));
+ invalidate_shared_roots();
+ await refresh();
+ } catch (e) {
+ show_error(e?.message ?? i18n('share_failed'));
+ $(this).prop('disabled', false);
+ }
+ });
+
+ await refresh();
+ return el_window;
+}
+
+export default UIWindowShare;
diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css
index 0a3ca51d03..3a1554faf4 100644
--- a/src/gui/src/css/style.css
+++ b/src/gui/src/css/style.css
@@ -6537,3 +6537,123 @@ html.dark-mode .usage-table-show-less:hover {
background: #1e1e22;
}
}
+
+/**
+ * Share dialog
+ */
+.share-dialog {
+ padding: 20px;
+}
+
+.share-dialog-row {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 15px;
+}
+
+.share-dialog .share-recipient {
+ flex: 1;
+}
+
+.share-dialog .share-mode {
+ width: 130px;
+ flex: none;
+}
+
+.share-dialog-heading {
+ font-size: 13px;
+ font-weight: 500;
+ color: #5f6b7a;
+ margin: 24px 0 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.share-dialog-empty {
+ font-size: 13px;
+ color: #7f8b99;
+ margin: 0;
+ padding: 6px 0;
+}
+
+.share-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 8px 0;
+ border-top: 1px solid #eef1f4;
+ font-size: 14px;
+}
+
+.share-row:first-child {
+ border-top: none;
+}
+
+.share-row-who {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.share-row-mode {
+ color: #7f8b99;
+ font-size: 12px;
+ margin-left: auto;
+ flex: none;
+}
+
+.share-dialog .share-row-mode-select {
+ width: auto;
+ min-width: 110px;
+ margin-left: auto;
+ flex: none;
+ padding: 4px 6px;
+ font-size: 13px;
+}
+
+.share-row-inherited {
+ color: #7f8b99;
+}
+
+.share-row-via {
+ font-size: 12px;
+ color: #9aa5b1;
+ flex: none;
+}
+
+.share-row-inherited .share-row-mode {
+ margin-left: 0;
+}
+
+.share-dialog .share-revoke {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ flex: none;
+ border: none;
+ border-radius: 4px;
+ background: none;
+ color: #6b7785;
+ cursor: pointer;
+}
+
+.share-dialog .share-revoke:hover:not(:disabled) {
+ background-color: #eceff2;
+ color: #d1242f;
+}
+
+.share-dialog .share-revoke:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+.share-row-owner {
+ color: #7f8b99;
+ font-size: 12px;
+ flex: none;
+ padding-right: 4px;
+}
diff --git a/src/gui/src/globals.js b/src/gui/src/globals.js
index c0724d13d9..2c789de3ec 100644
--- a/src/gui/src/globals.js
+++ b/src/gui/src/globals.js
@@ -93,6 +93,11 @@ if ( window.user !== undefined && window.user !== null ) {
}
window.root_dirname = 'Puter';
+// Not a real directory — items shared with this user live under their owners'
+// paths. Deliberately not path-shaped so it can never collide with a folder
+// someone actually creates.
+window.shared_path = 'puter://shared';
+
// user preferences, persisted across sessions, cached in localStorage
try {
window.user_preferences = JSON.parse(localStorage.getItem('user_preferences'));
diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js
index fd02387ef4..af15fcb18d 100644
--- a/src/gui/src/helpers.js
+++ b/src/gui/src/helpers.js
@@ -20,6 +20,8 @@
import get_html_element_from_options from './helpers/get_html_element_from_options.js';
import globToRegExp from './helpers/globToRegExp.js';
import item_icon from './helpers/item_icon.js';
+import { is_owned_by_me, trash_path_for } from './helpers/path_owner.js';
+import { invalidate_shared_roots } from './helpers/shared_access.js';
import truncate_filename from './helpers/truncate_filename.js';
import update_title_based_on_uploads from './helpers/update_title_based_on_uploads.js';
import update_username_in_gui from './helpers/update_username_in_gui.js';
@@ -691,6 +693,7 @@ window.update_auth_data = async (auth_token, user) => {
window.desktop_path = `/${ window.user.username }/Desktop`;
window.home_path = `/${ window.user.username}`;
window.public_path = `/${ window.user.username }/Public`;
+ window.shared_path = 'puter://shared';
if ( window.user !== null && !window.user.is_temp ) {
$('.user-options-login-btn, .user-options-create-account-btn').hide();
@@ -1719,6 +1722,10 @@ window.refresh_trash_state = async function () {
* @returns {Promise}
*/
window.move_items = async function (el_items, dest_path, is_undo = false) {
+ // The Shared view is a query, not a directory — nothing can be moved
+ // into it. Backstop for any drop target the surfaces fail to exclude.
+ if ( dest_path === window.shared_path ) return;
+
let move_op_id = window.operation_id++;
window.operation_cancelled[move_op_id] = false;
@@ -1786,8 +1793,17 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
continue;
}
+ // Deleting sends an item to its owner's trash, not yours.
+ const is_trashing = dest_path === window.trash_path;
+ const item_dest_path = is_trashing
+ ? trash_path_for(
+ $(el_item).attr('data-path'),
+ $(el_item).attr('data-owner'),
+ )
+ : dest_path;
+
// cannot move item to its own path, skip it
- if ( path.dirname($(el_item).attr('data-path')) === dest_path ) {
+ if ( path.dirname($(el_item).attr('data-path')) === item_dest_path ) {
// pause the progress-window timer while waiting for the user
clearTimeout(progwin_timeout);
await UIAlert(`Moving ${html_encode($(el_item).attr('data-name'))}
Cannot move item to its current location.`);
@@ -1843,7 +1859,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// --------------------------------------------------------
// Trashing
// --------------------------------------------------------
- if ( dest_path === window.trash_path ) {
+ if ( is_trashing ) {
new_name = $(el_item).attr('data-uid');
metadata = {
original_name: $(el_item).attr('data-name'),
@@ -1893,7 +1909,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// execute move
let resp = await puter.fs.move({
source: $(el_item).attr('data-uid'),
- destination: dest_path,
+ destination: item_dest_path,
overwrite: overwrite || overwrite_all,
// "Keep Both" conflict resolution: move under a deduped
// "name (1)" style name instead of overwriting
@@ -1908,7 +1924,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
let fsentry = resp.moved;
// path must use the real name from DB
- fsentry.path = path.join(dest_path, fsentry.name);
+ fsentry.path = path.join(item_dest_path, fsentry.name);
// skip next loop iteration because this iteration was successful
item_with_same_name_already_exists = false;
@@ -1943,7 +1959,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
});
// if trashing, close windows of trashed items and its descendants
- if ( dest_path === window.trash_path ) {
+ if ( is_trashing ) {
$(`.window[data-path="${html_encode($(el_item).attr('data-path'))}" i]`).close();
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${html_encode($(el_item).attr('data-path'))}/"]`).close();
@@ -1953,11 +1969,11 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
else {
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${html_encode($(el_item).attr('data-path'))}/"], .window[data-path="${html_encode($(el_item).attr('data-path'))}" i]`).each(function () {
- window.update_window_path(this, $(this).attr('data-path').replace($(el_item).attr('data-path'), path.join(dest_path, fsentry.name)));
+ window.update_window_path(this, $(this).attr('data-path').replace($(el_item).attr('data-path'), path.join(item_dest_path, fsentry.name)));
});
}
- if ( dest_path === window.trash_path ) {
+ if ( is_trashing ) {
// if trashing dir...
if ( $(el_item).attr('data-is_dir') === '1' ) {
// disassociate all its websites
@@ -1981,19 +1997,19 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// create new item on matching containers
const options = {
- appendTo: $(`.item-container[data-path="${html_encode(dest_path)}" i]`),
+ appendTo: $(`.item-container[data-path="${html_encode(item_dest_path)}" i]`),
immutable: fsentry.immutable || (fsentry.writable === false),
associated_app_name: fsentry.associated_app?.name,
uid: fsentry.uid,
path: fsentry.path,
icon: await item_icon(fsentry),
- name: (dest_path === window.trash_path) ? $(el_item).attr('data-name') : fsentry.name,
+ name: is_trashing ? $(el_item).attr('data-name') : fsentry.name,
is_dir: fsentry.is_dir,
size: fsentry.size,
type: fsentry.type,
modified: fsentry.modified,
is_selected: false,
- is_shared: (dest_path === window.trash_path) ? false : fsentry.is_shared,
+ is_shared: is_trashing ? false : fsentry.is_shared,
is_shortcut: fsentry.is_shortcut,
shortcut_to: fsentry.shortcut_to,
shortcut_to_path: fsentry.shortcut_to_path,
@@ -2039,7 +2055,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
});
//sort each container
- $(`.item-container[data-path="${html_encode(dest_path)}" i]`).each(function () {
+ $(`.item-container[data-path="${html_encode(item_dest_path)}" i]`).each(function () {
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
});
} catch ( err ) {
@@ -3177,6 +3193,10 @@ window.rename_file = async (options, new_name, old_name, old_path, el_item, el_i
new_name: new_name,
excludeSocketID: window.socket?.id,
success: async (fsentry) => {
+ // A renamed shared item is cached under its old path — drop the
+ // cache so mode lookups against the new path don't miss.
+ if ( ! is_owned_by_me(old_path) ) invalidate_shared_roots();
+
// Add action to actions_history for undo ability
if ( ! is_undo )
{
diff --git a/src/gui/src/helpers/actionIcons.js b/src/gui/src/helpers/actionIcons.js
new file mode 100644
index 0000000000..2f8e8b4bce
--- /dev/null
+++ b/src/gui/src/helpers/actionIcons.js
@@ -0,0 +1,43 @@
+/*
+ * 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 .
+ */
+
+// Inline action glyphs shared by the file UI.
+
+export const icons = {
+ document: ``,
+ files: ``,
+ folder: ``,
+ more: ``,
+ // Header action icons use the Material Symbols wght300 cut (one step
+ // lighter than the default 400) to match the thinned nav arrows.
+ newFolder: ``,
+ upload: ``,
+ trash: ``,
+ download: ``,
+ cut: ``,
+ copy: ``,
+ restore: ``,
+ list: ``,
+ grid: ``,
+ gridSmall: ``,
+ sort: ``,
+ select: ``,
+ done: ``,
+ worker: ``,
+};
diff --git a/src/gui/src/helpers/generate_file_context_menu.js b/src/gui/src/helpers/generate_file_context_menu.js
index a98b9f249b..19edbb8890 100644
--- a/src/gui/src/helpers/generate_file_context_menu.js
+++ b/src/gui/src/helpers/generate_file_context_menu.js
@@ -23,11 +23,14 @@ import UIWindowItemProperties from '../UI/UIWindowItemProperties.js';
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
import UIWindowPublishWorker from '../UI/UIWindowPublishWorker.js';
+import UIWindowShare from '../UI/UIWindowShare.js';
import publish_as_website from './publish_as_website.js';
import open_item from './open_item.js';
import launch_app from './launch_app.js';
import path from '../lib/path.js';
import { isWeblinkName, weblinkChangeIconMenuItem } from './weblink.js';
+import { is_owned_by_me } from './path_owner.js';
+import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from './shared_access.js';
/**
* Generates context menu items for file/folder operations
@@ -50,6 +53,27 @@ const generate_file_context_menu = async function (options) {
const fsentry = options.fsentry || {};
const is_trash = options.is_trash ?? false;
const is_trashed = options.is_trashed ?? false;
+ // Has its own share, so it is a row the Shared view listed and can be left.
+ const is_shared_root = $(options.element).attr('data-shared_with_me') === '1';
+ // Someone else's, however we got here — including items reached by opening
+ // a shared folder, which carry no share markers of their own.
+ const is_not_mine = !is_owned_by_me($(options.element).attr('data-path'));
+ // `manage` inherits downwards, so a file inside a folder you manage
+ // counts too — the row itself only carries a mode at a shared root.
+ const can_manage_share =
+ $(options.element).attr('data-share_mode') === 'manage'
+ || (await shared_mode_for($(options.element).attr('data-path'))) === 'manage';
+ // Moving and deleting go by the holding folder, not by the item.
+ const may_restructure = !is_not_mine
+ || await can_restructure($(options.element).attr('data-path'));
+ // A shared FILE you hold write on is renameable even though it can't be
+ // moved; a shared folder root is not.
+ const may_rename = !is_not_mine
+ || await can_rename(
+ $(options.element).attr('data-path'),
+ fsentry.is_dir === true
+ || ['1', 'true'].includes($(options.element).attr('data-is_dir')),
+ );
const is_worker = options.is_worker ?? false;
const onOpen = options.onOpen;
const is_weblink = isWeblinkName(fsentry.name ?? $(el_item).attr('data-name'));
@@ -292,10 +316,50 @@ const generate_file_context_menu = async function (options) {
menu_items.push(weblinkChangeIconMenuItem(el_item));
}
+ // -------------------------------------------
+ // Share
+ // -------------------------------------------
+ if ( !is_trash && !is_trashed && (!is_not_mine || can_manage_share) ) {
+ menu_items.push({
+ html: i18n('share_ellipsis'),
+ onClick: async function () {
+ UIWindowShare({
+ path: $(el_item).attr('data-path'),
+ name: $(el_item).attr('data-name'),
+ });
+ },
+ });
+ }
+
+ // -------------------------------------------
+ // Remove from Shared
+ // -------------------------------------------
+ // Can't trash someone else's file, so give up our own access instead. Only
+ // for an item shared directly — access to a child is held on the folder.
+ if ( is_shared_root ) {
+ menu_items.push({
+ html: i18n('share_remove_from_shared'),
+ onClick: async function () {
+ try {
+ await puter.fs.unshare(
+ $(el_item).attr('data-path'),
+ window.user.username,
+ );
+ // Or mode lookups keep answering for a share we just
+ // walked away from.
+ invalidate_shared_roots();
+ $(el_item).remove();
+ } catch (e) {
+ UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
+ }
+ },
+ });
+ }
+
// -------------------------------------------
// Delete
// -------------------------------------------
- if ( $(el_item).attr('data-immutable') === '0' && !is_trashed ) {
+ if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && may_restructure ) {
menu_items.push({
html: i18n('delete'),
onClick: async function () {
@@ -335,7 +399,7 @@ const generate_file_context_menu = async function (options) {
// -------------------------------------------
// Rename
// -------------------------------------------
- if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && !is_trash ) {
+ if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && !is_trash && may_rename ) {
menu_items.push({
html: i18n('rename'),
onClick: function () {
diff --git a/src/gui/src/helpers/list_all_shared.js b/src/gui/src/helpers/list_all_shared.js
new file mode 100644
index 0000000000..b9954cff75
--- /dev/null
+++ b/src/gui/src/helpers/list_all_shared.js
@@ -0,0 +1,46 @@
+/*
+ * 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 .
+ */
+
+// 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.
+ *
+ * @returns {Promise>}
+ */
+const list_all_shared = async () => {
+ const shares = [];
+ let cursor;
+
+ do {
+ const page = await window.puter.fs.listShared({
+ limit: PAGE_SIZE,
+ ...(cursor ? { cursor } : {}),
+ });
+ shares.push(...(page.items ?? []));
+ cursor = page.cursor;
+ } while ( cursor );
+
+ return shares;
+};
+
+export default list_all_shared;
diff --git a/src/gui/src/helpers/path_owner.js b/src/gui/src/helpers/path_owner.js
new file mode 100644
index 0000000000..1368f3a803
--- /dev/null
+++ b/src/gui/src/helpers/path_owner.js
@@ -0,0 +1,55 @@
+/*
+ * 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 .
+ */
+
+/**
+ * Owner of an item, read off its path — Puter paths are `/{username}/…`.
+ *
+ * @param {string} path
+ * @returns {string|null} null when the path names no owner (relative, or `/`).
+ */
+export const owner_of_path = (path) =>
+ typeof path === 'string' ? path.split('/').filter(Boolean)[0] ?? null : null;
+
+/**
+ * Whether the signed-in user owns the item at `path`.
+ *
+ * Works however the item was reached, which the `data-shared_with_me` marker
+ * does not: that is only set on rows the Shared view itself listed, so an item
+ * opened *inside* a shared folder arrives looking like one of your own.
+ * Unknown ownership counts as yours, leaving ordinary paths untouched.
+ *
+ * @param {string} path
+ * @returns {boolean}
+ */
+export const is_owned_by_me = (path) => {
+ const owner = owner_of_path(path);
+ return owner === null || owner === window.user?.username;
+};
+
+/**
+ * Trash an item belongs in — its owner's, not yours.
+ *
+ * @param {string} path
+ * @param {string} [owner] username from the entry, when known
+ * @returns {string}
+ */
+export const trash_path_for = (path, owner) => {
+ const from_path = path?.startsWith('~') ? null : owner_of_path(path);
+ return `/${owner || from_path || window.user?.username}/Trash`;
+};
diff --git a/src/gui/src/helpers/refresh_item_container.js b/src/gui/src/helpers/refresh_item_container.js
index 51956aef80..e3c6fff413 100644
--- a/src/gui/src/helpers/refresh_item_container.js
+++ b/src/gui/src/helpers/refresh_item_container.js
@@ -20,6 +20,8 @@
import path from '../lib/path.js';
import UIItem from '../UI/UIItem.js';
import item_icon from './item_icon.js';
+import list_all_shared from './list_all_shared.js';
+import { remember_shared_roots } from './shared_access.js';
const refresh_item_container = function (el_item_container, options) {
// start a transaction
@@ -73,7 +75,19 @@ const refresh_item_container = function (el_item_container, options) {
// --------------------------------------------------------
// Folder's configs and properties
// --------------------------------------------------------
- puter.fs.stat({ path: container_path, consistency: options.consistency ?? 'eventual' }).then(fsentry => {
+ // The Shared view is a query, not a directory — there is no fsentry to
+ // stat, and its entries live under their owners' paths.
+ const is_shared_view = container_path === window.shared_path;
+
+ if ( is_shared_view && el_window ) {
+ $(el_window).attr('data-uid', 'null');
+ $(el_window).find('.window-head-title').text(i18n('shared_with_me'));
+ if ( el_window_head_icon ) {
+ $(el_window_head_icon).attr('src', window.icons['shared.svg']);
+ }
+ }
+
+ if ( !is_shared_view ) puter.fs.stat({ path: container_path, consistency: options.consistency ?? 'eventual' }).then(fsentry => {
if ( el_window ) {
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
@@ -114,7 +128,32 @@ const refresh_item_container = function (el_item_container, options) {
$(el_item_container).find('.item').removeItems();
// get items with subdomains/workers included to avoid per-item stat calls
- puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' }).then(async (fsentries) => {
+ const entries_promise = is_shared_view
+ ? list_all_shared().then((shares) => {
+ remember_shared_roots(shares);
+ return shares;
+ }).then((shares) => shares.map((share) => ({
+ uid: share.entryUid,
+ // Share paths are masked (`/owner/uuid/name`), so prefer the name
+ // the share row carries over parsing it off the path.
+ name: share.name ?? path.basename(share.path),
+ path: share.path,
+ is_dir: share.isDir,
+ type: share.type,
+ thumbnail: share.thumbnail,
+ modified: share.modified,
+ size: share.size,
+ // Carried so the context menu can offer "remove from shared"
+ // rather than a delete the backend would refuse.
+ shared_with_me: true,
+ share_mode: share.mode,
+ shared_by: share.issuer,
+ owner: share.owner,
+ metadata: '',
+ })))
+ : puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' });
+
+ entries_promise.then(async (fsentries) => {
// Check if the same folder is still loading since el_item_container's
// data-path might have changed by other operations while waiting for the response to this `readdir`.
if ( $(el_item_container).attr('data-path') !== container_path )
@@ -212,6 +251,10 @@ const refresh_item_container = function (el_item_container, options) {
disabled: is_disabled,
visible: visible,
position: position,
+ shared_with_me: fsentry.shared_with_me,
+ share_mode: fsentry.share_mode,
+ shared_by: fsentry.shared_by,
+ owner: fsentry.owner?.username ?? fsentry.owner,
});
}
}
diff --git a/src/gui/src/helpers/share_paths.js b/src/gui/src/helpers/share_paths.js
new file mode 100644
index 0000000000..1d056f634b
--- /dev/null
+++ b/src/gui/src/helpers/share_paths.js
@@ -0,0 +1,85 @@
+/*
+ * 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 .
+ */
+
+/**
+ * Items other people share with you arrive as `/{owner}/{uid}/{name}[/…]`.
+ * The `{uid}` segment stands in for wherever the owner keeps the item, so the
+ * path is addressable without saying anything about their folders.
+ *
+ * Everything here reads that shape directly. Nothing needs the share listing,
+ * so it all works on a deep link or a restored window.
+ */
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/**
+ * @typedef {{owner: string, uid: string, segments: string[]}} SharedPathParts
+ */
+
+/**
+ * @param {string} abs_path
+ * @returns {SharedPathParts|null} null when the path is not a shared one
+ */
+export const parse_shared_path = (abs_path) => {
+ if ( typeof abs_path !== 'string' || ! abs_path.startsWith('/') ) return null;
+ const [owner, uid, ...segments] = abs_path.slice(1).split('/');
+ if ( ! owner || ! uid || ! UUID.test(uid) ) return null;
+ if ( segments.length === 0 ) return null;
+ return { owner, uid, segments };
+};
+
+/** The shared item itself, as opposed to something inside it. */
+export const is_share_root = (abs_path) =>
+ parse_shared_path(abs_path)?.segments.length === 1;
+
+/**
+ * Where the Up button goes. Above a shared item there is only the owner's own
+ * folder, which is not yours to open — so the Shared view stands in for it.
+ *
+ * @param {string} abs_path
+ * @returns {string}
+ */
+export const parent_path_for = (abs_path) => {
+ if ( abs_path === window.shared_path ) return abs_path;
+ if ( is_share_root(abs_path) ) return window.shared_path;
+ const parent = abs_path.slice(0, abs_path.lastIndexOf('/'));
+ return parent === '' ? '/' : parent;
+};
+
+/**
+ * @typedef {{label: string, path: string}} PathCrumb
+ */
+
+/**
+ * What the directory bar shows for a path. Only the label changes — every
+ * segment keeps the real path it navigates to.
+ *
+ * @param {string} abs_path
+ * @returns {PathCrumb[]|null} null when the path is the viewer's own
+ */
+export const shared_crumbs_for = (abs_path) => {
+ const parts = parse_shared_path(abs_path);
+ if ( ! parts || parts.owner === window.user?.username ) return null;
+
+ let cursor = `/${parts.owner}/${parts.uid}`;
+ return parts.segments.map((segment) => {
+ cursor += `/${segment}`;
+ return { label: segment, path: cursor };
+ });
+};
diff --git a/src/gui/src/helpers/share_paths.test.js b/src/gui/src/helpers/share_paths.test.js
new file mode 100644
index 0000000000..53550af580
--- /dev/null
+++ b/src/gui/src/helpers/share_paths.test.js
@@ -0,0 +1,115 @@
+/*
+ * 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 { beforeEach, describe, expect, it } from 'vitest';
+import {
+ is_share_root,
+ parent_path_for,
+ parse_shared_path,
+ shared_crumbs_for,
+} from './share_paths.js';
+
+const UID = '11111111-2222-3333-4444-555555555555';
+
+beforeEach(() => {
+ globalThis.window = {
+ user: { username: 'sharemate' },
+ shared_path: 'puter://shared',
+ };
+});
+
+describe('parse_shared_path', () => {
+ it('reads owner, uid and the segments below it', () => {
+ expect(parse_shared_path(`/jfcastro/${UID}/Contents/sub/f.txt`)).toEqual(
+ { owner: 'jfcastro', uid: UID, segments: ['Contents', 'sub', 'f.txt'] },
+ );
+ });
+
+ it('is not fooled by an ordinary path', () => {
+ expect(parse_shared_path('/jfcastro/Documents/f.txt')).toBeNull();
+ expect(parse_shared_path(`/jfcastro/${UID}`)).toBeNull();
+ expect(parse_shared_path('relative')).toBeNull();
+ });
+});
+
+describe('is_share_root', () => {
+ it('is true only for the shared item itself', () => {
+ expect(is_share_root(`/jfcastro/${UID}/Contents`)).toBe(true);
+ expect(is_share_root(`/jfcastro/${UID}/Contents/sub`)).toBe(false);
+ expect(is_share_root('/sharemate/Documents')).toBe(false);
+ });
+});
+
+describe('parent_path_for', () => {
+ it('sends the shared item up to Shared, not into the owner’s folder', () => {
+ expect(parent_path_for(`/jfcastro/${UID}/Contents`)).toBe(
+ 'puter://shared',
+ );
+ });
+
+ it('walks normally inside the shared item', () => {
+ expect(parent_path_for(`/jfcastro/${UID}/Contents/sub`)).toBe(
+ `/jfcastro/${UID}/Contents`,
+ );
+ });
+
+ it('stops at Shared', () => {
+ expect(parent_path_for('puter://shared')).toBe('puter://shared');
+ });
+
+ it('leaves ordinary paths to ordinary rules', () => {
+ expect(parent_path_for('/sharemate/Documents/a.txt')).toBe(
+ '/sharemate/Documents',
+ );
+ expect(parent_path_for('/sharemate')).toBe('/');
+ });
+});
+
+describe('shared_crumbs_for', () => {
+ it('leaves the viewer’s own paths unmasked', () => {
+ expect(shared_crumbs_for('/sharemate/Documents/a.txt')).toBeNull();
+ });
+
+ it('shows a shared item by its own name', () => {
+ expect(shared_crumbs_for(`/jfcastro/${UID}/_CodeSignature`)).toEqual([
+ { label: '_CodeSignature', path: `/jfcastro/${UID}/_CodeSignature` },
+ ]);
+ });
+
+ it('keeps the addressable path on every crumb below it', () => {
+ expect(
+ shared_crumbs_for(`/jfcastro/${UID}/Contents/sub/CodeResources`),
+ ).toEqual([
+ { label: 'Contents', path: `/jfcastro/${UID}/Contents` },
+ { label: 'sub', path: `/jfcastro/${UID}/Contents/sub` },
+ {
+ label: 'CodeResources',
+ path: `/jfcastro/${UID}/Contents/sub/CodeResources`,
+ },
+ ]);
+ });
+
+ it('never names the owner’s folders above the share', () => {
+ const labels = shared_crumbs_for(
+ `/jfcastro/${UID}/Contents/deep/f.txt`,
+ ).map((c) => c.label);
+ expect(labels).toEqual(['Contents', 'deep', 'f.txt']);
+ expect(labels).not.toContain('Documents');
+ });
+});
diff --git a/src/gui/src/helpers/shared_access.js b/src/gui/src/helpers/shared_access.js
new file mode 100644
index 0000000000..f6e238d9c2
--- /dev/null
+++ b/src/gui/src/helpers/shared_access.js
@@ -0,0 +1,142 @@
+/*
+ * 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 list_all_shared from './list_all_shared.js';
+import { is_owned_by_me } from './path_owner.js';
+import { is_share_root } from './share_paths.js';
+
+// What we hold on each shared root, by path: `{ mode, name }`. A `readdir`
+// inside a shared folder returns plain entries, so nothing else records it.
+const roots = new Map();
+let loaded = false;
+let inflight = null;
+
+/**
+ * Record what a Shared listing reported. Replaces rather than merges, so a
+ * share withdrawn elsewhere does not linger.
+ */
+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 });
+ }
+ loaded = true;
+};
+
+/** Drop what we know; the next lookup re-reads it. */
+export const invalidate_shared_roots = () => {
+ roots.clear();
+ loaded = false;
+ inflight = null;
+};
+
+// A deep link or restored window never ran the Shared listing, so fetch on
+// first use. One request per miss; concurrent callers share it.
+const load_once = () => {
+ if ( loaded ) return Promise.resolve();
+ inflight ??= list_all_shared()
+ .then(remember_shared_roots)
+ .catch(() => {
+ // Retry next time; a miss only hides an action, so never block.
+ })
+ .finally(() => {
+ inflight = null;
+ });
+ return inflight;
+};
+
+/**
+ * Mode held on `path` or on the nearest shared ancestor of it.
+ *
+ * @param {string} path
+ * @returns {Promise}
+ */
+export const shared_mode_for = async (path) => {
+ if ( typeof path !== 'string' || path === '' ) return null;
+ await load_once();
+ return shared_root_for(path)?.mode ?? null;
+};
+
+/**
+ * Whether anything is shared with the user at all.
+ *
+ * @returns {Promise}
+ */
+export const has_shared_roots = async () => {
+ await load_once();
+ return roots.size > 0;
+};
+
+/**
+ * The shared root `path` sits in, from what is already loaded.
+ *
+ * @param {string} path
+ * @returns {{path: string, mode: string, name: string|undefined}|null}
+ */
+export const shared_root_for = (path) => {
+ if ( typeof path !== 'string' || path === '' ) return null;
+ let best = null;
+ for ( const root of roots.keys() ) {
+ if ( path !== root && ! path.startsWith(`${root}/`) ) continue;
+ if ( best === null || root.length > best.length ) best = root;
+ }
+ return best === null ? null : { path: best, ...roots.get(best) };
+};
+
+/**
+ * May you rename the item at `item_path`?
+ *
+ * A FILE shared directly with you renames with `write` on it — the name is
+ * the file's own. A folder's name is structure the owner's subtree hangs
+ * off, so a shared folder root stays fixed; everything reached inside a
+ * shared folder goes by the holding folder, exactly like moving or deleting.
+ * The backend authorizes rename the same way.
+ *
+ * @param {string} item_path
+ * @param {boolean} [is_dir]
+ * @returns {Promise}
+ */
+export const can_rename = async (item_path, is_dir = false) => {
+ if ( typeof item_path !== 'string' ) return false;
+ if ( is_owned_by_me(item_path) ) return true;
+ if ( is_share_root(item_path) ) {
+ if ( is_dir ) return false;
+ return ['write', 'manage'].includes(await shared_mode_for(item_path));
+ }
+ return can_restructure(item_path);
+};
+
+/**
+ * May you move or delete the item at `item_path`?
+ *
+ * The folder holding it decides, which is what the backend enforces too. A
+ * shared item is therefore fixed — its folder belongs to its owner — while
+ * anything inside a folder you can write to is yours to reorganize.
+ *
+ * @param {string} item_path
+ * @returns {Promise}
+ */
+export const can_restructure = async (item_path) => {
+ if ( typeof item_path !== 'string' ) return false;
+ if ( is_owned_by_me(item_path) ) return true;
+ if ( is_share_root(item_path) ) return false;
+ const parent = item_path.slice(0, item_path.lastIndexOf('/'));
+ return ['write', 'manage'].includes(await shared_mode_for(parent));
+};
diff --git a/src/gui/src/helpers/shared_access.test.js b/src/gui/src/helpers/shared_access.test.js
new file mode 100644
index 0000000000..e767e246c7
--- /dev/null
+++ b/src/gui/src/helpers/shared_access.test.js
@@ -0,0 +1,153 @@
+/*
+ * 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 { beforeEach, describe, expect, it } from 'vitest';
+import {
+ can_rename,
+ can_restructure,
+ invalidate_shared_roots,
+ remember_shared_roots,
+ shared_mode_for,
+} from './shared_access.js';
+
+const CONTENTS = '11111111-1111-1111-1111-111111111111';
+const PHOTOS = '22222222-2222-2222-2222-222222222222';
+const BUDGET = '33333333-3333-3333-3333-333333333333';
+const REPORT = '44444444-4444-4444-4444-444444444444';
+
+describe('shared_access', () => {
+ beforeEach(() => {
+ invalidate_shared_roots();
+ globalThis.window = { user: { username: 'sharemate' } };
+ // Shared roots arrive masked: `/{owner}/{uid}/{name}`.
+ remember_shared_roots([
+ { path: `/jf/${CONTENTS}/Contents`, mode: 'write' },
+ { path: `/jf/${PHOTOS}/Photos`, mode: 'read' },
+ { path: `/jf/${BUDGET}/Budget`, mode: 'manage' },
+ { path: `/jf/${REPORT}/report.pdf`, mode: 'write' },
+ ]);
+ });
+
+ describe('shared_mode_for', () => {
+ it('reports the mode held on a shared root', async () => {
+ expect(await shared_mode_for(`/jf/${PHOTOS}/Photos`)).toBe('read');
+ });
+
+ it('inherits the mode down into the folder', async () => {
+ expect(await shared_mode_for(`/jf/${PHOTOS}/Photos/2024/a.jpg`)).toBe('read');
+ });
+
+ it('prefers the nearest shared ancestor', async () => {
+ remember_shared_roots([
+ { path: '/jf/Documents', mode: 'read' },
+ { path: `/jf/${CONTENTS}/Contents`, mode: 'write' },
+ ]);
+ expect(await shared_mode_for(`/jf/${CONTENTS}/Contents/a.txt`)).toBe(
+ 'write',
+ );
+ });
+
+ it('reports nothing outside every shared root', async () => {
+ expect(await shared_mode_for(`/jf/${CONTENTS}/Private/a.txt`)).toBe(null);
+ });
+ });
+
+ describe('can_restructure', () => {
+ it('allows an item inside a folder shared for writing', async () => {
+ expect(
+ await can_restructure(`/jf/${CONTENTS}/Contents/a.txt`),
+ ).toBe(true);
+ });
+
+ it('allows an item nested deeper in that folder', async () => {
+ expect(
+ await can_restructure(`/jf/${CONTENTS}/Contents/sub/a.txt`),
+ ).toBe(true);
+ });
+
+ it('allows an item inside a folder shared for managing', async () => {
+ expect(await can_restructure(`/jf/${BUDGET}/Budget/q1.xlsx`)).toBe(true);
+ });
+
+ it('refuses the shared folder itself', async () => {
+ expect(await can_restructure(`/jf/${CONTENTS}/Contents`)).toBe(false);
+ });
+
+ it('refuses a file shared directly', async () => {
+ expect(await can_restructure(`/jf/${REPORT}/report.pdf`)).toBe(false);
+ });
+
+ it('refuses inside a folder shared read-only', async () => {
+ expect(await can_restructure(`/jf/${PHOTOS}/Photos/a.jpg`)).toBe(false);
+ });
+
+ it('refuses a path that is not shared at all', async () => {
+ expect(await can_restructure(`/jf/${CONTENTS}/Private/a.txt`)).toBe(false);
+ });
+
+ it('refuses a non-string path', async () => {
+ expect(await can_restructure(undefined)).toBe(false);
+ });
+
+ it('allows your own items, shared or not', async () => {
+ expect(await can_restructure('/sharemate/Documents/a.txt')).toBe(
+ true,
+ );
+ });
+ });
+
+ describe('can_rename', () => {
+ it('allows a file shared directly for writing', async () => {
+ expect(await can_rename(`/jf/${REPORT}/report.pdf`)).toBe(true);
+ });
+
+ it('refuses a shared folder root, even with write', async () => {
+ expect(await can_rename(`/jf/${CONTENTS}/Contents`, true)).toBe(false);
+ });
+
+ it('refuses a shared folder root held with manage', async () => {
+ expect(await can_rename(`/jf/${BUDGET}/Budget`, true)).toBe(false);
+ });
+
+ it('allows items inside a folder shared for writing', async () => {
+ expect(await can_rename(`/jf/${CONTENTS}/Contents/a.txt`)).toBe(true);
+ expect(
+ await can_rename(`/jf/${CONTENTS}/Contents/sub`, true),
+ ).toBe(true);
+ });
+
+ it('refuses anything shared read-only', async () => {
+ expect(await can_rename(`/jf/${PHOTOS}/Photos`, true)).toBe(false);
+ expect(await can_rename(`/jf/${PHOTOS}/Photos/a.jpg`)).toBe(false);
+ });
+
+ it('refuses a path that is not shared at all', async () => {
+ expect(await can_rename(`/jf/${CONTENTS}/Private/a.txt`)).toBe(false);
+ });
+
+ it('refuses a non-string path', async () => {
+ expect(await can_rename(undefined)).toBe(false);
+ });
+
+ it('allows your own items', async () => {
+ expect(await can_rename('/sharemate/Documents/a.txt')).toBe(true);
+ expect(await can_rename('/sharemate/Documents', true)).toBe(true);
+ });
+ });
+});
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js
index 3b318fcb46..017787addd 100644
--- a/src/gui/src/i18n/translations/en.js
+++ b/src/gui/src/i18n/translations/en.js
@@ -372,7 +372,29 @@ const en = {
keyboard_shortcuts_permanent_delete: 'Permanently delete (after confirmation)',
set_new_password: 'Set New Password',
share: 'Share',
+ share_ellipsis: 'Share…',
share_to: 'Share to',
+ shared: 'Shared',
+ shared_with_me: 'Shared with me',
+ shared_by: 'Shared by',
+ share_access_read: 'Can view',
+ share_access_write: 'Can edit',
+ share_access_manage: 'Can edit & share',
+ share_add_people: 'Add people by email or username',
+ share_who_has_access: 'Who has access',
+ share_no_one: 'Not shared with anyone yet.',
+ share_owner: 'Owner',
+ share_remove_access: 'Remove access',
+ share_remove_from_shared: 'Remove from Shared',
+ share_nothing_shared: 'Nothing has been shared with you yet.',
+ share_done: 'Done',
+ share_failed: 'Could not share this item.',
+ share_shared_with: 'Shared with {{recipient}}',
+ share_access_removed: 'Removed {{recipient}}',
+ share_confirm_remove: 'Remove {{recipient}}’s access to this item?',
+ share_remove: 'Remove',
+ share_you: 'you',
+ share_inherited_via: 'via {{folder}}',
share_with: 'Share with:',
shortcut_to: 'Shortcut to',
show_all_windows: 'Show All Windows',
diff --git a/src/gui/src/icons/folder-shared.svg b/src/gui/src/icons/folder-shared.svg
new file mode 100644
index 0000000000..3934678470
--- /dev/null
+++ b/src/gui/src/icons/folder-shared.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/gui/src/icons/sidebar-folder-shared.svg b/src/gui/src/icons/sidebar-folder-shared.svg
new file mode 100644
index 0000000000..3934678470
--- /dev/null
+++ b/src/gui/src/icons/sidebar-folder-shared.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/gui/src/index.js b/src/gui/src/index.js
index f39473dbf7..f0f4a5cfdd 100644
--- a/src/gui/src/index.js
+++ b/src/gui/src/index.js
@@ -75,7 +75,7 @@ window.gui = async (options) => {
else if ( window.gui_env === 'prod' ) {
// This stuff is now handled in the backend in PuterHomepageService
- await window.loadScript('https://js.puter.com/v2/');
+ await window.loadScript(options.puterjs_bundle ?? 'https://js.puter.com/v2/');
// Load the minified bundles
// await window.loadCSS('/dist/bundle.min.css');
}
diff --git a/src/gui/src/keyboard.js b/src/gui/src/keyboard.js
index 3523bf15e0..5ce7665e6b 100644
--- a/src/gui/src/keyboard.js
+++ b/src/gui/src/keyboard.js
@@ -884,6 +884,11 @@ $(document).bind('keyup keydown', async function (e) {
{
return;
}
+ // ... or into the Shared view — a query, not a directory
+ if ( target_path === window.shared_path )
+ {
+ return;
+ }
// execute clipboard operation
if ( window.clipboard_op === 'copy' )
{
diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts
index 504e7726e8..3cee3cafae 100644
--- a/src/puter-js/index.d.ts
+++ b/src/puter-js/index.d.ts
@@ -96,14 +96,22 @@ export type {
export type {
CopyOptions,
DeleteOptions,
+ GetSharesOptions,
+ ListSharedOptions,
MkdirOptions,
MoveOptions,
ReadOptions,
ReaddirOptions,
RenameOptions,
+ Share,
+ ShareMode,
+ ShareOptions,
+ SharePage,
+ ShareRecipient,
SignResult,
SpaceInfo,
StatOptions,
+ UnshareOptions,
UploadBatchError,
UploadItems,
UploadOperationResult,
diff --git a/src/puter-js/src/modules/FileSystem/index.js b/src/puter-js/src/modules/FileSystem/index.js
index 4597815929..278e551f68 100644
--- a/src/puter-js/src/modules/FileSystem/index.js
+++ b/src/puter-js/src/modules/FileSystem/index.js
@@ -13,6 +13,8 @@ import FSItem from '../FSItem.js';
import copy from './operations/copy.js';
import deleteFSEntry from './operations/deleteFSEntry.js';
import getReadURL from './operations/getReadUrl.js';
+import getShares from './operations/getShares.js';
+import listShared from './operations/listShared.js';
import mkdir from './operations/mkdir.js';
import move from './operations/move.js';
import read from './operations/read.js';
@@ -20,9 +22,11 @@ import readdir from './operations/readdir.js';
import readdirSubdomains from './operations/readdirSubdomains.js';
import rename from './operations/rename.js';
import revokeReadURL from './operations/revokeReadUrl.js';
+import share from './operations/share.js';
import sign from './operations/sign.js';
import space from './operations/space.js';
import stat from './operations/stat.js';
+import unshare from './operations/unshare.js';
import upload from './operations/upload/index.js';
import write from './operations/write.js';
@@ -55,6 +59,12 @@ export class PuterJSFileSystemModule extends PuterModule {
readdirSubdomains = readdirSubdomains;
stat = stat;
+ // Sharing
+ share = share;
+ unshare = unshare;
+ listShared = listShared;
+ getShares = getShares;
+
FSItem = FSItem;
/**
diff --git a/src/puter-js/src/modules/FileSystem/operations/getShares.js b/src/puter-js/src/modules/FileSystem/operations/getShares.js
new file mode 100644
index 0000000000..7f72cdc929
--- /dev/null
+++ b/src/puter-js/src/modules/FileSystem/operations/getShares.js
@@ -0,0 +1,42 @@
+import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
+import { defineOperation } from './scaffold.js';
+import { toShare } from './shareUtil.js';
+
+/** @typedef {import('../types.js').GetSharesOptions} GetSharesOptions */
+/** @typedef {import('../types.js').Share} Share */
+
+/**
+ * Lists who can reach a file or directory you can manage.
+ *
+ * Includes shares granted by anyone holding `manage` on the item, not only
+ * your own — which is how an owner sees what a delegate has re-shared.
+ *
+ * @type {{
+ * (options: GetSharesOptions): Promise,
+ * (
+ * path: string,
+ * success?: (value: Share[]) => void,
+ * error?: (reason: unknown) => void,
+ * ): Promise,
+ * }}
+ */
+const getShares = defineOperation({
+ positional: ['path'],
+ request (options) {
+ const query = new URLSearchParams();
+ if ( options.uid !== undefined ) {
+ query.set('uid', String(options.uid));
+ } else {
+ query.set('path', getAbsolutePathForApp(String(options.path)));
+ }
+
+ return {
+ endpoint: `/share/shares?${query.toString()}`,
+ method: 'get',
+ transform: (/** @type {{ items?: Record[] }} */ response) =>
+ (response.items ?? []).map(toShare),
+ };
+ },
+});
+
+export default getShares;
diff --git a/src/puter-js/src/modules/FileSystem/operations/listShared.js b/src/puter-js/src/modules/FileSystem/operations/listShared.js
new file mode 100644
index 0000000000..51bf2d5f32
--- /dev/null
+++ b/src/puter-js/src/modules/FileSystem/operations/listShared.js
@@ -0,0 +1,44 @@
+import { defineOperation, firstDefined } from './scaffold.js';
+import { toShare } from './shareUtil.js';
+
+/** @typedef {import('../types.js').ListSharedOptions} ListSharedOptions */
+/** @typedef {import('../types.js').SharePage} SharePage */
+
+/**
+ * Lists what other users have shared with you, a page at a time.
+ *
+ * `cursor` comes back only while more pages remain, so iterate until it is
+ * absent rather than comparing `items.length` to `limit` — a page can be short
+ * once items the caller can no longer see are filtered out.
+ *
+ * @type {{
+ * (options?: ListSharedOptions): Promise,
+ * (
+ * success?: (value: SharePage) => void,
+ * error?: (reason: unknown) => void,
+ * ): Promise,
+ * }}
+ */
+const listShared = defineOperation({
+ request (options) {
+ const query = new URLSearchParams();
+ if ( options.limit !== undefined ) query.set('limit', String(options.limit));
+ if ( options.cursor !== undefined ) query.set('cursor', String(options.cursor));
+ if ( firstDefined(options, 'includeTotal', 'include_total') ) {
+ query.set('includeTotal', 'true');
+ }
+ const suffix = query.toString();
+
+ return {
+ endpoint: `/share/shared-with-me${suffix ? `?${suffix}` : ''}`,
+ method: 'get',
+ transform: (/** @type {{ items?: Record[], cursor?: string, total?: number }} */ response) => ({
+ items: (response.items ?? []).map(toShare),
+ ...(response.cursor === undefined ? {} : { cursor: response.cursor }),
+ ...(response.total === undefined ? {} : { total: response.total }),
+ }),
+ };
+ },
+});
+
+export default listShared;
diff --git a/src/puter-js/src/modules/FileSystem/operations/share.js b/src/puter-js/src/modules/FileSystem/operations/share.js
new file mode 100644
index 0000000000..ea3b42f1a6
--- /dev/null
+++ b/src/puter-js/src/modules/FileSystem/operations/share.js
@@ -0,0 +1,60 @@
+import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
+import { defineOperation, firstDefined } from './scaffold.js';
+import { toShare, toShareItems, toShareRecipients } from './shareUtil.js';
+
+/** @typedef {import('../types.js').ShareOptions} ShareOptions */
+/** @typedef {import('../types.js').ShareMode} ShareMode */
+/** @typedef {import('../types.js').ShareRecipient} ShareRecipient */
+/** @typedef {import('../types.js').Share} Share */
+
+/**
+ * Gives another Puter user access to a file or directory. Relative paths
+ * resolve against the app's root directory.
+ *
+ * Resolves with one {@link Share} per recipient/item pair that succeeded. A
+ * pair that fails — an unknown recipient, say — does not fail the others; its
+ * error is reported on the rejected pair only when every pair failed.
+ *
+ * @type {{
+ * (options: ShareOptions): Promise,
+ * (
+ * path: string,
+ * recipient: ShareRecipient | ShareRecipient[],
+ * mode?: ShareMode,
+ * success?: (value: Share[]) => void,
+ * error?: (reason: unknown) => void,
+ * ): Promise,
+ * }}
+ */
+const share = defineOperation({
+ positional: ['path', 'recipient', 'mode'],
+ request (options) {
+ const recipients = toShareRecipients(
+ firstDefined(options, 'recipient', 'recipients'),
+ );
+ const items = toShareItems(options, (path) => getAbsolutePathForApp(path));
+
+ return {
+ endpoint: '/share',
+ body: {
+ recipients,
+ items,
+ mode: options.mode ?? 'read',
+ },
+ transform: (/** @type {{ status: string, results: Record[] }} */ response) => {
+ const results = response.results ?? [];
+ const ok = results.filter((r) => r.status === 'success');
+ if ( ok.length === 0 && results.length > 0 ) {
+ const first = results[0];
+ throw {
+ message: String(first.message ?? 'Share failed'),
+ code: String(first.code ?? 'share_failed'),
+ };
+ }
+ return ok.map(toShare);
+ },
+ };
+ },
+});
+
+export default share;
diff --git a/src/puter-js/src/modules/FileSystem/operations/shareUtil.js b/src/puter-js/src/modules/FileSystem/operations/shareUtil.js
new file mode 100644
index 0000000000..fad25ad809
--- /dev/null
+++ b/src/puter-js/src/modules/FileSystem/operations/shareUtil.js
@@ -0,0 +1,78 @@
+// Shared helpers for the sharing operations.
+
+import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
+
+/** @typedef {import('../types.js').Share} Share */
+/** @typedef {import('../types.js').ShareRecipient} ShareRecipient */
+
+/**
+ * Normalizes recipients into the wire form. A bare string is read as an email
+ * when it contains `@`, and as a username otherwise.
+ *
+ * @param {unknown} value
+ * @returns {Array<{ email?: string, username?: string }>}
+ */
+export const toShareRecipients = (value) => {
+ const list = Array.isArray(value) ? value : [value];
+ return list
+ .filter((entry) => entry !== undefined && entry !== null)
+ .map((entry) => {
+ if ( typeof entry === 'string' ) {
+ const trimmed = entry.trim();
+ return trimmed.includes('@')
+ ? { email: trimmed }
+ : { username: trimmed };
+ }
+ const record = /** @type {Record} */ (entry);
+ return {
+ ...(record.email ? { email: String(record.email) } : {}),
+ ...(record.username ? { username: String(record.username) } : {}),
+ };
+ });
+};
+
+/**
+ * Collects whichever of `path`, `paths` or `uid` the caller supplied into the
+ * wire form. Paths are made absolute; UIDs are passed through.
+ *
+ * @param {Record} options
+ * @param {(path: string) => string} [resolvePath]
+ * @returns {Array<{ path?: string, uid?: string }>}
+ */
+export const toShareItems = (options, resolvePath = getAbsolutePathForApp) => {
+ if ( options.uid !== undefined ) {
+ const uids = Array.isArray(options.uid) ? options.uid : [options.uid];
+ return uids.map((uid) => ({ uid: String(uid) }));
+ }
+ const raw = options.paths !== undefined ? options.paths : options.path;
+ const paths = Array.isArray(raw) ? raw : [raw];
+ return paths
+ .filter((path) => path !== undefined && path !== null)
+ .map((path) => ({ path: resolvePath(String(path)) }));
+};
+
+/**
+ * Turns one wire share into the shape the SDK publishes.
+ *
+ * @param {Record} row
+ * @returns {Share}
+ */
+export const toShare = (row) => ({
+ uid: /** @type {string} */ (row.uid),
+ mode: /** @type {Share['mode']} */ (row.mode),
+ path: /** @type {string} */ (row.path),
+ entryUid: /** @type {string} */ (row.uid_entry ?? row.entryUid),
+ isDir: Boolean(row.is_dir ?? row.isDir),
+ // A share listing has no fsentry behind it to stat, so the row carries
+ // what a file browser needs to render the item. Absent elsewhere.
+ name: /** @type {string | null} */ (row.name ?? null),
+ type: /** @type {string | null} */ (row.type ?? null),
+ thumbnail: /** @type {string | null} */ (row.thumbnail ?? null),
+ owner: /** @type {string | null} */ (row.owner ?? null),
+ issuer: /** @type {string | null} */ (row.issuer ?? null),
+ holder: /** @type {string | null} */ (row.holder ?? null),
+ inheritedFrom: /** @type {string | null} */ (row.inherited_from ?? null),
+ issuedByApp: /** @type {string | null} */ (row.issued_by_app ?? null),
+ modified: /** @type {number} */ (row.modified ?? 0),
+ size: /** @type {number | null} */ (row.size ?? null),
+});
diff --git a/src/puter-js/src/modules/FileSystem/operations/unshare.js b/src/puter-js/src/modules/FileSystem/operations/unshare.js
new file mode 100644
index 0000000000..73db094b3d
--- /dev/null
+++ b/src/puter-js/src/modules/FileSystem/operations/unshare.js
@@ -0,0 +1,46 @@
+import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
+import { defineOperation, firstDefined } from './scaffold.js';
+import { toShareItems, toShareRecipients } from './shareUtil.js';
+
+/** @typedef {import('../types.js').UnshareOptions} UnshareOptions */
+/** @typedef {import('../types.js').ShareRecipient} ShareRecipient */
+
+/**
+ * Withdraws a user's access to a file or directory.
+ *
+ * The item's owner can withdraw any share of it, whoever granted it. Anyone
+ * else can withdraw the shares they granted, or their own access — pass
+ * yourself as the recipient to leave a share someone else gave you.
+ *
+ * Resolves with the number of grants actually removed, which is `0` when there
+ * was nothing to withdraw.
+ *
+ * @type {{
+ * (options: UnshareOptions): Promise<{ revoked: number }>,
+ * (
+ * path: string,
+ * recipient: ShareRecipient,
+ * success?: (value: { revoked: number }) => void,
+ * error?: (reason: unknown) => void,
+ * ): Promise<{ revoked: number }>,
+ * }}
+ */
+const unshare = defineOperation({
+ positional: ['path', 'recipient'],
+ request (options) {
+ return {
+ endpoint: '/share/revoke',
+ body: {
+ recipients: toShareRecipients(
+ firstDefined(options, 'recipient', 'recipients'),
+ ),
+ items: toShareItems(options, (path) => getAbsolutePathForApp(path)),
+ },
+ transform: (/** @type {{ revoked?: number }} */ response) => ({
+ revoked: Number(response.revoked ?? 0),
+ }),
+ };
+ },
+});
+
+export default unshare;
diff --git a/src/puter-js/src/modules/FileSystem/types.js b/src/puter-js/src/modules/FileSystem/types.js
index 4c1324bf73..46aa8b83f6 100644
--- a/src/puter-js/src/modules/FileSystem/types.js
+++ b/src/puter-js/src/modules/FileSystem/types.js
@@ -270,4 +270,103 @@
* | unknown[]} UploadItems
*/
+/**
+ * How much access a share grants. Stronger modes imply the weaker ones, so
+ * `write` also allows reading, and `manage` — the strongest — allows
+ * everything `write` does plus re-sharing the item with other people.
+ *
+ * @typedef {'see' | 'list' | 'read' | 'write' | 'manage'} ShareMode
+ */
+
+/**
+ * Who a share is for. Give an `email` or a `username`; a bare string is read
+ * as an email when it contains `@` and a username otherwise.
+ *
+ * @typedef {string | { email?: string, username?: string }} ShareRecipient
+ */
+
+/**
+ * One live share.
+ *
+ * @typedef {Object} Share
+ * @property {string} uid Identifier for this share.
+ * @property {ShareMode} mode Access the recipient has.
+ * @property {string} path Path of the shared item.
+ * @property {string} entryUid UID of the shared item.
+ * @property {boolean} isDir Whether the shared item is a directory.
+ * @property {string | null} name The item's name. Only set by `listShared()`.
+ * @property {string | null} type The item's content type, or `'folder'`. Only
+ * set by `listShared()`.
+ * @property {string | null} thumbnail URL of the item's thumbnail, if it has
+ * one. Only set by `listShared()`.
+ * @property {string | null} owner Username of the item's owner. Only set by
+ * `listShared()`.
+ * @property {string | null} issuer Username of whoever granted it.
+ * @property {string | null} holder Username of whoever received it.
+ * @property {string | null} [inheritedFrom] Shared ancestor this access comes from, if any.
+ * @property {string | null} [issuedByApp] UID of the app that asked for this
+ * share, or `null` when a person made it directly.
+ * @property {number} modified Last-modified time of the item, unix seconds.
+ * @property {number | null} size Size of the item in bytes; null for a directory.
+ */
+
+/**
+ * @typedef {Object} ShareOptionsOwn
+ * @property {string} [path] Item to share. Relative paths resolve against the
+ * app's root directory.
+ * @property {string} [uid] Item to share, by UID. Use instead of `path`.
+ * @property {string[]} [paths] Several items to share in one call.
+ * @property {ShareRecipient | ShareRecipient[]} [recipient] Who to share with.
+ * @property {ShareRecipient | ShareRecipient[]} [recipients] Alias for
+ * `recipient`.
+ * @property {ShareMode} [mode] Access to grant. Defaults to `'read'`.
+ */
+
+/**
+ * @typedef {ShareOptionsOwn & RequestCallbacks} ShareOptions
+ */
+
+/**
+ * @typedef {Object} UnshareOptionsOwn
+ * @property {string} [path] Item to stop sharing.
+ * @property {string} [uid] Item to stop sharing, by UID.
+ * @property {ShareRecipient} [recipient] Who to withdraw access from. Pass
+ * yourself to leave a share someone else granted you.
+ */
+
+/**
+ * @typedef {UnshareOptionsOwn & RequestCallbacks<{ revoked: number }>} UnshareOptions
+ */
+
+/**
+ * @typedef {Object} ListSharedOptionsOwn
+ * @property {number} [limit] Maximum shares per page.
+ * @property {string} [cursor] Continuation token from a previous page.
+ * @property {boolean} [includeTotal] Include the total count in the response.
+ */
+
+/**
+ * @typedef {ListSharedOptionsOwn & RequestCallbacks} ListSharedOptions
+ */
+
+/**
+ * A page of shares. `cursor` is present only while more pages remain, so
+ * iterate until it is absent rather than counting items.
+ *
+ * @typedef {Object} SharePage
+ * @property {Share[]} items
+ * @property {string} [cursor]
+ * @property {number} [total]
+ */
+
+/**
+ * @typedef {Object} GetSharesOptionsOwn
+ * @property {string} [path] Item to inspect.
+ * @property {string} [uid] Item to inspect, by UID.
+ */
+
+/**
+ * @typedef {GetSharesOptionsOwn & RequestCallbacks} GetSharesOptions
+ */
+
export {};
diff --git a/src/puter-js/tests/api/suites/index.ts b/src/puter-js/tests/api/suites/index.ts
index e3312b9023..5eda206c21 100644
--- a/src/puter-js/tests/api/suites/index.ts
+++ b/src/puter-js/tests/api/suites/index.ts
@@ -9,6 +9,7 @@ import kv from './kv.suite.ts';
import net from './net.suite.ts';
import os from './os.suite.ts';
import perms from './perms.suite.ts';
+import sharing from './sharing.suite.ts';
import system from './system.suite.ts';
import util from './util.suite.ts';
import workers from './workers.suite.ts';
@@ -28,6 +29,7 @@ export const suites: Suite[] = [
net,
os,
perms,
+ sharing,
system,
util,
workers,
diff --git a/src/puter-js/tests/api/suites/sharing.suite.ts b/src/puter-js/tests/api/suites/sharing.suite.ts
new file mode 100644
index 0000000000..664026b361
--- /dev/null
+++ b/src/puter-js/tests/api/suites/sharing.suite.ts
@@ -0,0 +1,200 @@
+import { suite } from '../harness/types.ts';
+import type { TestContext } from '../harness/types.ts';
+
+const home = (t: TestContext) => `/${t.env.users.user.username}`;
+
+/** A unique path under the acting user's home. */
+const scratch = (t: TestContext, label: string) =>
+ `${home(t)}/sharing-${label}-${Math.random().toString(36).slice(2, 8)}.txt`;
+
+/** Read a file as the `other` user — plain fetch, so it works everywhere. */
+const readAsOther = (t: TestContext, path: string) =>
+ fetch(`${t.env.apiOrigin}/read?${new URLSearchParams({ file: path })}`, {
+ headers: {
+ Authorization: `Bearer ${t.env.users.other.token}`,
+ Origin: t.env.apiOrigin,
+ },
+ });
+
+export default suite('sharing', {
+ 'share gives another user access, unshare takes it back': async (t) => {
+ const path = scratch(t, 'roundtrip');
+ await t.puter.fs.write(path, 'shared content');
+
+ const before = await readAsOther(t, path);
+ t.assert.ok(
+ before.status !== 200,
+ `should not read before sharing (got ${before.status})`,
+ );
+
+ const shares = await t.puter.fs.share(
+ path,
+ t.env.users.other.username,
+ 'read',
+ );
+ t.assert.equal(shares.length, 1);
+ t.assert.equal(shares[0].mode, 'read');
+ t.assert.equal(shares[0].holder, t.env.users.other.username);
+
+ const after = await readAsOther(t, path);
+ t.assert.equal(after.status, 200);
+ t.assert.equal(await after.text(), 'shared content');
+
+ const revoked = await t.puter.fs.unshare(
+ path,
+ t.env.users.other.username,
+ );
+ t.assert.equal(revoked.revoked, 1);
+
+ const afterRevoke = await readAsOther(t, path);
+ t.assert.ok(
+ afterRevoke.status !== 200,
+ `read should fail after unshare (got ${afterRevoke.status})`,
+ );
+ },
+
+ 'share accepts an options object and defaults to read': async (t) => {
+ const path = scratch(t, 'options');
+ await t.puter.fs.write(path, 'x');
+
+ const shares = await t.puter.fs.share({
+ path,
+ recipient: { username: t.env.users.other.username },
+ });
+ t.assert.equal(shares[0].mode, 'read');
+ t.assert.equal(shares[0].path, path);
+ },
+
+ 'getShares reports who can reach an item': async (t) => {
+ const path = scratch(t, 'getshares');
+ await t.puter.fs.write(path, 'x');
+ await t.puter.fs.share(path, t.env.users.other.username, 'write');
+
+ const shares = await t.puter.fs.getShares(path);
+ t.assert.equal(shares.length, 1);
+ t.assert.equal(shares[0].holder, t.env.users.other.username);
+ t.assert.equal(shares[0].mode, 'write');
+ t.assert.equal(shares[0].issuer, t.env.users.user.username);
+ },
+
+ 'changing the mode replaces the share rather than adding one': async (t) => {
+ const path = scratch(t, 'remode');
+ await t.puter.fs.write(path, 'x');
+
+ await t.puter.fs.share(path, t.env.users.other.username, 'read');
+ await t.puter.fs.share(path, t.env.users.other.username, 'write');
+
+ const shares = await t.puter.fs.getShares(path);
+ t.assert.equal(shares.length, 1);
+ t.assert.equal(shares[0].mode, 'write');
+ },
+
+ 'listShared returns a page envelope with a total': async (t) => {
+ const path = scratch(t, 'listed');
+ await t.puter.fs.write(path, 'x');
+ await t.puter.fs.share(path, t.env.users.other.username, 'read');
+
+ const page = await t.puter.fs.listShared({ includeTotal: true });
+ t.assert.ok(Array.isArray(page.items), 'items should be an array');
+ t.assert.equal(typeof page.total, 'number');
+ // The sharer is not the holder, so their own item is not listed here.
+ t.assert.ok(
+ !page.items.some((share) => share.path === path),
+ 'sharer should not see their own item in shared-with-me',
+ );
+ },
+
+ 'sharing an unknown recipient rejects': async (t) => {
+ const path = scratch(t, 'nobody');
+ await t.puter.fs.write(path, 'x');
+
+ let failed = false;
+ try {
+ await t.puter.fs.share(path, 'no-such-user-zzz', 'read');
+ } catch (e) {
+ failed = true;
+ t.assert.ok(
+ typeof (e as { code?: string }).code === 'string',
+ 'error should carry a code',
+ );
+ }
+ t.assert.ok(failed, 'sharing with an unknown user should reject');
+ },
+
+ 'a recipient gets a masked path that still resolves': async (t) => {
+ const path = scratch(t, 'masked');
+ await t.puter.fs.write(path, 'masked content');
+ await t.puter.fs.share(path, t.env.users.other.username, 'read');
+
+ const page = await fetch(
+ `${t.env.apiOrigin}/share/shared-with-me?limit=200`,
+ {
+ headers: {
+ Authorization: `Bearer ${t.env.users.other.token}`,
+ Origin: t.env.apiOrigin,
+ },
+ },
+ ).then((r) => r.json() as Promise<{ items: Record[] }>);
+
+ const listed = page.items.find((item) => item.uid_entry);
+ const shared = page.items.find(
+ (item) => item.name === path.split('/').pop(),
+ );
+ t.assert.ok(listed && shared, 'the share should be listed');
+
+ // The exact masked shape: owner, entry uid, leaf name — and nothing
+ // of the owner's tree between them. The backend still resolves it.
+ t.assert.equal(
+ shared!.path,
+ `${home(t)}/${shared!.uid_entry}/${shared!.name}`,
+ );
+
+ const read = await fetch(
+ `${t.env.apiOrigin}/read?${new URLSearchParams({ file: shared!.path })}`,
+ {
+ headers: {
+ Authorization: `Bearer ${t.env.users.other.token}`,
+ Origin: t.env.apiOrigin,
+ },
+ },
+ );
+ t.assert.equal(read.status, 200);
+ t.assert.equal(await read.text(), 'masked content');
+ },
+
+ 'listShared carries what a file browser needs to render an item': async (
+ t,
+ ) => {
+ const path = scratch(t, 'render');
+ await t.puter.fs.write(path, 'x');
+ await t.puter.fs.share(path, t.env.users.other.username, 'read');
+
+ const page = await fetch(
+ `${t.env.apiOrigin}/share/shared-with-me?limit=200`,
+ {
+ headers: {
+ Authorization: `Bearer ${t.env.users.other.token}`,
+ Origin: t.env.apiOrigin,
+ },
+ },
+ ).then((r) => r.json() as Promise<{ items: Record[] }>);
+
+ const shared = page.items.find(
+ (item) => item.name === path.split('/').pop(),
+ );
+ t.assert.ok(shared, 'the share should be listed');
+ t.assert.equal(shared!.owner, t.env.users.user.username);
+ t.assert.equal(typeof shared!.type, 'string');
+ },
+
+ 'unsharing something never shared reports nothing revoked': async (t) => {
+ const path = scratch(t, 'noop');
+ await t.puter.fs.write(path, 'x');
+
+ const result = await t.puter.fs.unshare(
+ path,
+ t.env.users.other.username,
+ );
+ t.assert.equal(result.revoked, 0);
+ },
+});