diff --git a/app/components/FileItem.tsx b/app/components/FileItem.tsx
index 2d99afe..fe776bc 100644
--- a/app/components/FileItem.tsx
+++ b/app/components/FileItem.tsx
@@ -3,6 +3,7 @@
import { useState, useRef } from "react";
import { getFileIcon, formatFileSize, formatDate, type FileType } from "../lib/helpers";
import FileItemMenu from "./FileItemMenu";
+import type { AccessEntry, AccessResult } from "./FileList";
export type { FileType };
@@ -30,6 +31,7 @@ interface FileItemProps {
onShare?: (file: FileItemData) => void;
isSelected?: boolean;
onContextMenu?: (file: FileItemData, event: React.MouseEvent) => void;
+ accessResult?: AccessResult | null | "loading";
}
export default function FileItem({
@@ -46,6 +48,7 @@ export default function FileItem({
onShare,
isSelected = false,
onContextMenu,
+ accessResult,
}: FileItemProps) {
const [isHovered, setIsHovered] = useState(false);
const clickCountRef = useRef(0);
@@ -116,6 +119,184 @@ export default function FileItem({
lastTapRef.current = currentTime;
};
+ const [hoveredCategory, setHoveredCategory] = useState(null);
+
+ type Category = { key: string; label: string; dotColor: string; chipClass: string; entries: AccessEntry[]; };
+
+ const buildCategories = (): { categories: Category[]; allInherited: boolean; someInherited: boolean } | null => {
+ if (accessResult === undefined || accessResult === "loading" || accessResult === null) return null;
+ const entries = accessResult.entries;
+ if (entries.length === 0) return { categories: [], allInherited: false, someInherited: false };
+
+ const publicEntries = entries.filter(e => e.isPublic);
+ const authenticatedEntries = entries.filter(e => e.isAuthenticated);
+ const agentEntries = entries.filter(e => !e.isPublic && !e.isAuthenticated);
+
+ const categories: Category[] = [];
+
+ if (publicEntries.length > 0) {
+ const modes = [...new Set(publicEntries.flatMap(e => e.modes))];
+ categories.push({
+ key: "public",
+ label: `Public: ${modes.join(", ")}`,
+ dotColor: "bg-red-400",
+ chipClass: "bg-red-50 text-red-800",
+ entries: publicEntries,
+ });
+ }
+
+ if (authenticatedEntries.length > 0) {
+ const modes = [...new Set(authenticatedEntries.flatMap(e => e.modes))];
+ categories.push({
+ key: "authenticated",
+ label: `Authenticated: ${modes.join(", ")}`,
+ dotColor: "bg-orange-400",
+ chipClass: "bg-orange-50 text-orange-800",
+ entries: authenticatedEntries,
+ });
+ }
+
+ if (agentEntries.length > 0) {
+ const modes = [...new Set(agentEntries.flatMap(e => e.modes))];
+ categories.push({
+ key: "agents",
+ label: `Shared: ${modes.join(", ")}`,
+ dotColor: "bg-purple-400",
+ chipClass: "bg-purple-50 text-purple-800",
+ entries: agentEntries,
+ });
+ }
+
+ return {
+ categories,
+ allInherited: entries.every(e => e.inherited),
+ someInherited: entries.some(e => e.inherited),
+ };
+ };
+
+ const renderPermissions = () => {
+ if (accessResult === undefined) return null;
+ if (accessResult === "loading") {
+ return (
+
+ );
+ }
+ if (accessResult === null) {
+ return error;
+ }
+
+ const result = buildCategories();
+ if (!result) return null;
+ const { categories, allInherited, someInherited } = result;
+
+ if (categories.length === 0) {
+ return (
+
+ Private
+
+ );
+ }
+
+ return (
+
+ {categories.map((cat) => (
+
setHoveredCategory(cat.key)}
+ onMouseLeave={() => setHoveredCategory(null)}
+ >
+
+ {cat.label}
+
+ {hoveredCategory === cat.key && (
+
+ {cat.entries.map((entry) => (
+
+ {entry.rawAgent || entry.agent}
+ — {entry.modes.join(", ")}
+
+ ))}
+
+ )}
+
+ ))}
+ {someInherited && (
+
+ {allInherited ? "(inherited)" : "(partly inherited)"}
+
+ )}
+
+ );
+ };
+
+ const renderPermissionDots = () => {
+ if (accessResult === undefined) return null;
+ if (accessResult === "loading") {
+ return (
+
+ );
+ }
+ if (accessResult === null) return null;
+
+ const result = buildCategories();
+ if (!result) return null;
+ const { categories } = result;
+
+ if (categories.length === 0) {
+ return (
+ setHoveredCategory("private")}
+ onMouseLeave={() => setHoveredCategory(null)}
+ >
+
+ {hoveredCategory === "private" && (
+
+ Private — only the owner
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {categories.map((cat) => (
+
setHoveredCategory(cat.key)}
+ onMouseLeave={() => setHoveredCategory(null)}
+ >
+
+ {hoveredCategory === cat.key && (
+
+
{cat.label}
+ {cat.entries.map((entry) => (
+
+ {entry.rawAgent || entry.agent}
+ — {entry.modes.join(", ")}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+ };
+
if (view === "grid") {
return (
+ {renderPermissionDots()}
);
}
- // List view
+ // List view — always use grid layout for consistent alignment
+ const hasPermissions = accessResult !== undefined;
+
return (
setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={handleClick}
@@ -179,30 +369,37 @@ export default function FileItem({
onContextMenu?.(file, event);
}}
>
-
+
{getFileIcon(file.type, file.mimeType)}
-
+
-
+ {hasPermissions && (
+
+ {renderPermissions()}
+
+ )}
+
{file.lastModified && formatDate(file.lastModified)}
-
+
{file.size && formatFileSize(file.size)}
{isHovered && (
-
+
+
+
)}
);
diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx
index 74362ef..e531267 100644
--- a/app/components/FileList.tsx
+++ b/app/components/FileList.tsx
@@ -1,9 +1,13 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef } from "react";
import FileItem, { FileItemData } from "./FileItem";
import Toolbar from "./shared/Toolbar";
import EmptyState from "./shared/EmptyState";
+import { getResourceAccessList, type AccessEntry, type AccessResult } from "../lib/helpers/acpUtils";
+
+export type { AccessEntry, AccessResult };
+type PermissionsMap = Record
;
interface FileListProps {
files: FileItemData[];
@@ -19,6 +23,8 @@ interface FileListProps {
onFileShare?: (file: FileItemData) => void;
selectedFileIds: string[];
onFileContextMenu?: (file: FileItemData, event: React.MouseEvent) => void;
+ showPermissions?: boolean;
+ onTogglePermissions?: () => void;
}
const VIEW_STORAGE_KEY = "solid-file-manager-view";
@@ -37,6 +43,8 @@ export default function FileList({
onFileShare,
selectedFileIds,
onFileContextMenu,
+ showPermissions,
+ onTogglePermissions,
}: FileListProps) {
const [view, setView] = useState<"grid" | "list">(() => {
if (typeof window === "undefined") return "list";
@@ -44,6 +52,53 @@ export default function FileList({
return (stored === "grid" || stored === "list") ? stored : "list";
});
+ const [permissionsState, setPermissionsState] = useState<{ path: string; map: PermissionsMap }>({ path: currentPath, map: {} });
+ const fetchedUrlsRef = useRef>(new Set());
+
+ // Derive the current map, resetting if path changed
+ const permissionsMap = permissionsState.path === currentPath ? permissionsState.map : {};
+
+ // Fetch permissions for all visible files when toggle is on
+ useEffect(() => {
+ fetchedUrlsRef.current = new Set();
+
+ if (!showPermissions || files.length === 0) return;
+
+ let cancelled = false;
+
+ const fetchAll = async () => {
+ const toFetch = files.filter(f => !fetchedUrlsRef.current.has(f.url));
+ if (toFetch.length === 0) return;
+
+ // Mark all as loading
+ const loadingMap: PermissionsMap = {};
+ for (const f of toFetch) {
+ loadingMap[f.url] = "loading";
+ fetchedUrlsRef.current.add(f.url);
+ }
+ if (!cancelled) setPermissionsState({ path: currentPath, map: loadingMap });
+
+ // Fetch in parallel
+ await Promise.all(
+ toFetch.map(async (file) => {
+ try {
+ const resourceUrl = file.type === "folder" && !file.url.endsWith("/")
+ ? file.url + "/"
+ : file.url;
+ const list = await getResourceAccessList(resourceUrl);
+ if (!cancelled) setPermissionsState(prev => ({ ...prev, map: { ...prev.map, [file.url]: list } }));
+ } catch {
+ if (!cancelled) setPermissionsState(prev => ({ ...prev, map: { ...prev.map, [file.url]: null } }));
+ }
+ })
+ );
+ };
+
+ fetchAll();
+
+ return () => { cancelled = true; };
+ }, [showPermissions, currentPath, files]);
+
useEffect(() => {
localStorage.setItem(VIEW_STORAGE_KEY, view);
}, [view]);
@@ -54,6 +109,8 @@ export default function FileList({
view={view}
onViewChange={setView}
itemCount={files.length}
+ showPermissions={showPermissions}
+ onTogglePermissions={onTogglePermissions}
/>
{/* File List/Grid */}
@@ -78,11 +135,23 @@ export default function FileList({
onShare={onFileShare}
isSelected={selectedFileIds.includes(file.id)}
onContextMenu={onFileContextMenu}
+ accessResult={showPermissions ? permissionsMap[file.url] : undefined}
/>
))}
) : (
+ {showPermissions && (
+
+
+
Name
+
Permissions
+
Modified
+
Size
+
+ )}
{files.map((file) => (
))}
diff --git a/app/components/FileManager.tsx b/app/components/FileManager.tsx
index 554af85..9d333df 100644
--- a/app/components/FileManager.tsx
+++ b/app/components/FileManager.tsx
@@ -99,6 +99,7 @@ export default function FileManager() {
const [showShareSuccessModal, setShowShareSuccessModal] = useState(false);
const [sharedResourceUrl, setSharedResourceUrl] = useState
("");
const [sharedResourceName, setSharedResourceName] = useState("");
+ const [showPermissions, setShowPermissions] = useState(false);
const closeContextMenu = () => setContextMenuState(null);
@@ -882,6 +883,8 @@ export default function FileManager() {
onFileShare={handleShare}
selectedFileIds={selectedFileIds}
onFileContextMenu={handleFileContextMenu}
+ showPermissions={showPermissions}
+ onTogglePermissions={() => setShowPermissions(prev => !prev)}
/>
)}
diff --git a/app/components/ShareDialog.tsx b/app/components/ShareDialog.tsx
index 79cd11c..e58f08a 100644
--- a/app/components/ShareDialog.tsx
+++ b/app/components/ShareDialog.tsx
@@ -7,7 +7,7 @@ import UrlCombobox, { ComboboxOption } from "./shared/UrlCombobox";
import { FileItemData } from "./FileItem";
import { fetchUserContacts, Contact, addContactToProfile } from "../lib/helpers/contactUtils";
import { fetchAndParseProfile } from "../lib/helpers/profileUtils";
-import { getResourceAccessList, removeAccessFromResource } from "../lib/helpers/acpUtils";
+import { getResourceAccessList, removeAccessFromResource, type AccessResult } from "../lib/helpers/acpUtils";
import { UserIcon, MagnifyingGlassIcon, LockClosedIcon, XMarkIcon, CheckCircleIcon, TrashIcon } from "@heroicons/react/24/outline";
import LoadingSpinner from "./shared/LoadingSpinner";
@@ -39,7 +39,7 @@ export default function ShareDialog({
const [peopleChips, setPeopleChips] = useState
([]);
const [isAddingWebId, setIsAddingWebId] = useState(false);
const [isSharing, setIsSharing] = useState(false);
- const [accessList, setAccessList] = useState | null>(null);
+ const [accessResult, setAccessResult] = useState(null);
const [isLoadingAccessList, setIsLoadingAccessList] = useState(false);
const [removingWebId, setRemovingWebId] = useState(null);
@@ -61,8 +61,8 @@ export default function ShareDialog({
setIsLoadingAccessList(true);
const resourceUrl = file.type === "folder" && !file.url.endsWith("/") ? file.url + "/" : file.url;
getResourceAccessList(resourceUrl)
- .then((list) => {
- setAccessList(list);
+ .then((result) => {
+ setAccessResult(result);
setIsLoadingAccessList(false);
})
.catch((error) => {
@@ -74,7 +74,7 @@ export default function ShareDialog({
setWebIdInput("");
setSelectedAccessLevel("Editor");
setPeopleChips([]);
- setAccessList(null);
+ setAccessResult(null);
setRemovingWebId(null);
}
}, [isOpen, file]);
@@ -88,8 +88,8 @@ export default function ShareDialog({
await removeAccessFromResource(resourceUrl, webIdToRemove);
// Refresh the access list
- const updatedList = await getResourceAccessList(resourceUrl);
- setAccessList(updatedList);
+ const updatedResult = await getResourceAccessList(resourceUrl);
+ setAccessResult(updatedResult);
} catch (error) {
console.error("Failed to remove access:", error);
} finally {
@@ -212,8 +212,8 @@ export default function ShareDialog({
// Refresh access list after sharing
if (file) {
const resourceUrl = file.type === "folder" && !file.url.endsWith("/") ? file.url + "/" : file.url;
- const updatedList = await getResourceAccessList(resourceUrl);
- setAccessList(updatedList);
+ const updatedResult = await getResourceAccessList(resourceUrl);
+ setAccessResult(updatedResult);
}
onClose();
@@ -333,7 +333,7 @@ export default function ShareDialog({
{/* People with access section */}
- {accessList && accessList.length > 0 && (
+ {accessResult && accessResult.entries.length > 0 && (
People with access
@@ -342,30 +342,31 @@ export default function ShareDialog({
) : (
- accessList.map((access, index) => {
- const hasWrite = access.accessModes.some((mode) => mode.includes("Write"));
+ accessResult.entries.map((access, index) => {
+ const hasWrite = access.modes.some((mode) => mode === "Write");
const accessLevel = hasWrite ? "Editor" : "Viewer";
- const isRemoving = removingWebId === access.webId;
-
+ const displayLabel = access.isPublic ? "Anyone (Public)" : access.isAuthenticated ? "Authenticated users" : access.agent;
+ const isRemoving = removingWebId === access.agent;
+
return (
- {access.webId}
+ {displayLabel}
{accessLevel}