From 5ac3124e8646d08799a894b4e6399fa69340480e Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 25 Oct 2026 09:41:32 +1100 Subject: [PATCH 1/2] Fix the navigation issues with storage as well add to friends button did not appear on compact profile header Prompt: when the user is on their "storage" container the full view of folder pane should appear, the storage in navbar should show the sidebar and the content no folder pane header. Co-authored-by: ClaudeOpus 5.5 --- .../profile-heading/ProfileHeading.styles.css | 4 + .../profile-heading/ProfileHeading.ts | 4 +- src/mainPage/index.ts | 15 ++- src/mainPage/profileHeading.ts | 7 +- src/navigation/chrome.ts | 32 +++++- src/navigation/controller.ts | 36 +++++- src/navigation/options.ts | 14 +++ src/navigation/podContext.ts | 10 +- src/outline/manager.js | 106 ++++++++++++++---- test/unit/mainPage/index.test.ts | 2 +- .../mainPage/profileHeadingFriends.test.ts | 17 ++- test/unit/navigation/chrome.test.ts | 36 ++++++ test/unit/navigation/controller.test.ts | 94 +++++++++++++++- test/unit/navigation/podContext.test.ts | 1 + test/unit/navigation/storagePane.test.ts | 10 ++ 15 files changed, 346 insertions(+), 42 deletions(-) diff --git a/src/components/profile-heading/ProfileHeading.styles.css b/src/components/profile-heading/ProfileHeading.styles.css index b1c04103..ed988b04 100644 --- a/src/components/profile-heading/ProfileHeading.styles.css +++ b/src/components/profile-heading/ProfileHeading.styles.css @@ -241,6 +241,10 @@ align-self: center; } +:host([compact]) .profile__heading-edit-action { + align-self: center; +} + :host([compact]) .name { white-space: normal; overflow-wrap: anywhere; diff --git a/src/components/profile-heading/ProfileHeading.ts b/src/components/profile-heading/ProfileHeading.ts index 1198fc5c..759250c6 100644 --- a/src/components/profile-heading/ProfileHeading.ts +++ b/src/components/profile-heading/ProfileHeading.ts @@ -221,7 +221,7 @@ export default class ProfileHeading extends WebComponent { - ${(this.editContext || showFriend) && !this.compact + ${(this.editContext && !this.compact) || showFriend ? html`
${showFriend @@ -246,7 +246,7 @@ export default class ProfileHeading extends WebComponent {
` : nothing} - ${this.editContext + ${this.editContext && !this.compact ? html` { + resetPodContexts() + navigation.show({ kind: 'restore', forceHeading: true }).catch((error: unknown) => { + console.error('Failed to refresh navigation after an authentication change.', error) + }) + } + authSession.events.on('login', refreshNavigationAfterAuth) + authSession.events.on('sessionRestore', refreshNavigationAfterAuth) + authSession.events.on('logout', refreshNavigationAfterAuth) + authSession.events.on('sessionChange', refreshNavigationAfterAuth) + authSession.events.on('identityReplaced', refreshNavigationAfterAuth) await navigation.show({ kind: 'restore', subject: subject.uri }) const header = await createHeader(navigation) diff --git a/src/mainPage/profileHeading.ts b/src/mainPage/profileHeading.ts index 084f8fff..15720cc8 100644 --- a/src/mainPage/profileHeading.ts +++ b/src/mainPage/profileHeading.ts @@ -7,7 +7,7 @@ import type { ViewerMode } from '../types' import { loadProfileFromURI } from '../utils/webIdUtils' import '../components/profile-heading' import './profileHeading.css' -import { deriveHeading } from '../navigation/chrome' +import { deriveHeading, offersFriendAction } from '../navigation/chrome' import { getPodContext } from '../navigation/podContext' import { onNavigated } from '../navigation/events' import { readNavigationState } from '../navigation/state' @@ -212,7 +212,7 @@ export async function refreshProfileHeading (force = false): Promise { } heading.profileData = profileData const viewer = authn.currentUser() - heading.friendSubject = isProfilePane && viewer && !viewer.sameTerm(subject) + heading.friendSubject = offersFriendAction(decision.paneName) && viewer && !viewer.sameTerm(subject) ? subject : undefined heading.editContext = canEdit @@ -243,7 +243,7 @@ export function placeProfileHeading (): void { ensureMountPoint() } -export function initializeProfileHeading (): void { +export function initializeProfileHeading (): Promise { ensureMountPoint() if (!profileSavedListenerRegistered) { @@ -296,4 +296,5 @@ export function initializeProfileHeading (): void { readiness.then(() => refreshProfileHeading(true)).catch((error: unknown) => { console.error('Failed to refresh profile heading.', error) }) + return readiness } diff --git a/src/navigation/chrome.ts b/src/navigation/chrome.ts index 7cd9eacc..5a37a295 100644 --- a/src/navigation/chrome.ts +++ b/src/navigation/chrome.ts @@ -9,6 +9,7 @@ */ import { type NavigationView } from './state' +import type { FolderView } from './options' /** Registry names of the panes that present a person rather than a resource. */ export const PERSON_PANES = ['profile', 'social'] @@ -17,6 +18,11 @@ export function isPersonPane (paneName?: string): boolean { return paneName !== undefined && PERSON_PANES.includes(paneName) } +/** Whether the heading over this pane offers to add its person as a friend: on the Profile and Friends tabs. */ +export function offersFriendAction (paneName?: string): boolean { + return isPersonPane(paneName) +} + /** The parts of a navbar item that the rules look at. */ export interface NavbarItemRef { paneName?: string @@ -121,6 +127,24 @@ export function isNavbarHidden (input: { viaAccountMenu: boolean, ownProfile: bo return input.viaAccountMenu || input.ownProfile || input.inViewerPod } +// Storage + +export interface StorageViewInput { + viaAccountMenu: boolean + /** The storage is one of the viewer's own. */ + inViewerPod: boolean + /** The storage belongs to a person whose profile was found; the server root has none. */ + hasOwner: boolean +} + +/** + * The viewer's own storage, and the server root, come with the folder pane's storage header. + * Someone else's storage sits under the navbar of their pod, which already says whose it is. + */ +export function deriveStorageView (input: StorageViewInput): Extract { + return input.viaAccountMenu || input.inViewerPod || !input.hasOwner ? 'explorer' : 'storage' +} + // File explorer header export interface ExplorerHeaderInput { @@ -140,7 +164,11 @@ export function deriveExplorerHeader (input: ExplorerHeaderInput): boolean { return !input.viewerPodOnly || input.inViewerPod } -/** The header is never shown over a person's profile or friends, whatever the resource. */ -export function explorerHeaderForPane (resourceHeader: boolean, paneName?: string): boolean { +/** + * The header is never shown over a person's profile or friends, whatever the resource, + * nor over a storage view, which has its own header or the navbar. + */ +export function explorerHeaderForPane (resourceHeader: boolean, paneName?: string, folderView?: FolderView): boolean { + if (paneName === 'folder' && (folderView === 'explorer' || folderView === 'storage')) return false return resourceHeader && !isPersonPane(paneName) } diff --git a/src/navigation/controller.ts b/src/navigation/controller.ts index 2d405409..01fbfdc6 100644 --- a/src/navigation/controller.ts +++ b/src/navigation/controller.ts @@ -13,8 +13,10 @@ import type { OutlineManager } from '../outline/manager' import { getFolderPaneForStorage, getProfilePaneFromURI, getSocialPaneFromURI, type PaneItem } from '../utils/paneUtils' import { setNavbarHidden } from './navbarVisibility' import { readNavigationState, type NavigationState, type NavigationView } from './state' -import type { NavigationSource } from './options' +import type { FolderView, NavigationSource } from './options' import { isWebIdUri } from '../utils/webIdUtils' +import { deriveStorageView } from './chrome' +import { getPodContext, isInViewerPod, type PodContext } from './podContext' export type NavigationIntent = /** Display what the current history entry says was displayed, for the page's own URL. */ @@ -42,6 +44,8 @@ export interface NavigationDeps { outliner: OutlineManager byName: (name: string) => PaneDefinition | null | undefined effects: NavigationEffects + /** Whose pod a URL is in; `getPodContext` unless a test supplies its own. */ + podContextOf?: (url: string) => Promise } export interface NavigationController { @@ -55,9 +59,28 @@ export interface NavigationController { type DisplayedPane = PaneItem | PaneDefinition | null | undefined export function createNavigationController (deps: NavigationDeps): NavigationController { - const { store, outliner, byName, effects } = deps + const { store, outliner, byName, effects, podContextOf = getPodContext } = deps let latest = 0 + /** The storage pane for a page that is a storage of its pod, or the server root. */ + async function storageAt (subject: NamedNode): Promise { + if (!subject.uri.endsWith('/')) return undefined + const isServerRoot = subject.site().uri === subject.uri + const { storagePanes, viewerStorages } = await podContextOf(subject.uri) + const isStorage = storagePanes.some(pane => pane.subject.uri === subject.uri) || + viewerStorages.includes(subject.uri) + return isServerRoot || isStorage ? getFolderPaneForStorage(subject) : undefined + } + + async function folderViewFor (storage: string, viaAccountMenu: boolean): Promise { + const context = viaAccountMenu ? undefined : await podContextOf(storage) + return deriveStorageView({ + viaAccountMenu, + inViewerPod: !!context && isInViewerPod(context, storage), + hasOwner: !!context?.owner + }) + } + async function paneForView (subject: NamedNode, view: NavigationView): Promise { // These panes render a subject other than the page URL. if (view.subject) { @@ -67,7 +90,8 @@ export function createNavigationController (deps: NavigationDeps): NavigationCon if (view.kind === 'storage') return getFolderPaneForStorage(paneSubject) } if (view.pane) return byName(view.pane) ?? undefined - return isWebIdUri(subject) ? getProfilePaneFromURI(subject, byName) : undefined + // With nothing recorded, the URL decides: a WebID shows its profile, a storage its storage view + return isWebIdUri(subject) ? getProfilePaneFromURI(subject, byName) : storageAt(subject) } async function paneFor (intent: NavigationIntent, subject: NamedNode, state: NavigationState): Promise { @@ -101,7 +125,11 @@ export function createNavigationController (deps: NavigationDeps): NavigationCon return } - outliner.GotoSubject(subject, true, pane ?? undefined, true, undefined, undefined, { source }) + const storage = pane && pane.name === 'folder' ? (pane as PaneItem).subject?.uri : undefined + const folderView = storage ? await folderViewFor(storage, source === 'accountMenu') : undefined + if (request !== latest) return + + outliner.GotoSubject(subject, true, pane ?? undefined, true, undefined, undefined, folderView ? { source, folderView } : { source }) if (source !== 'accountMenu') { const { view } = readNavigationState() diff --git a/src/navigation/options.ts b/src/navigation/options.ts index 5e93c49a..00102f56 100644 --- a/src/navigation/options.ts +++ b/src/navigation/options.ts @@ -6,8 +6,22 @@ */ export type NavigationSource = 'default' | 'accountMenu' +/** + * How the folder pane presents a container, when the caller decides it: + * - `explorer`: storage header, sidebar and content + * - `storage`: sidebar and content, under the navbar of someone else's pod + * - `container`: the container alone + */ +export type FolderView = 'explorer' | 'storage' | 'container' + export interface GotoSubjectOptions { source?: NavigationSource + folderView?: FolderView +} + +/** The folder view a `GotoSubject` caller asked for, if any. */ +export function resolveFolderView (argument?: boolean | GotoSubjectOptions | null): FolderView | undefined { + return typeof argument === 'object' && argument !== null ? argument.folderView : undefined } /** diff --git a/src/navigation/podContext.ts b/src/navigation/podContext.ts index 143963f1..71f6d2a0 100644 --- a/src/navigation/podContext.ts +++ b/src/navigation/podContext.ts @@ -81,8 +81,9 @@ function ownerOf (pageUrl: string): Promise { }, undefined) } -function storagesOf (pageUrl: string): Promise { - return remember(storages, podKey(pageUrl), 'storages of the pod', () => getFolderPanesFromURI(sym(pageUrl)), []) +function storagesOf (pageUrl: string, owner?: string): Promise { + const storageSubject = owner ? sym(owner) : sym(pageUrl) + return remember(storages, podKey(pageUrl), 'storages of the pod', () => getFolderPanesFromURI(storageSubject), []) } function storagesOfViewer (viewer: string | undefined): Promise { @@ -95,9 +96,10 @@ function storagesOfViewer (viewer: string | undefined): Promise { /** The context for the pod a URL is in; the page's own URL by default. */ export async function getPodContext (pageUrl: string = window.location.href): Promise { const viewer = authn.currentUser()?.value - const [owner, storagePanes, ownStorages] = await Promise.all([ - ownerOf(pageUrl), storagesOf(pageUrl), storagesOfViewer(viewer) + const [owner, ownStorages] = await Promise.all([ + ownerOf(pageUrl), storagesOfViewer(viewer) ]) + const storagePanes = await storagesOf(pageUrl, owner) return { viewer, owner, storagePanes, viewerStorages: ownStorages } } diff --git a/src/outline/manager.js b/src/outline/manager.js index b48c72c1..513de6d1 100644 --- a/src/outline/manager.js +++ b/src/outline/manager.js @@ -17,12 +17,13 @@ import { deriveExplorerHeader, explorerHeaderForPane } from '../navigation/chrom import { isPageInViewerPod } from '../navigation/podContext' import { nextHistoryState } from '../navigation/state' import { announceNavigation } from '../navigation/events' -import { resolveSource } from '../navigation/options' +import { resolveFolderView, resolveSource } from '../navigation/options' import { refreshNavbarVisibility } from '../navigation/navbarVisibility' import { createOutlineDomHelpers } from './outlineDomHelpers' import { getPaneIcon, getPaneItems } from './fileExplorerHelpers' import 'solid-ui/components/file-explorer-provider' import 'solid-ui/components/file-explorer-header' +import 'solid-ui/components/error-state' import AccessControlModal from 'solid-ui/components/access-control-modal' const EXPLORER_PROVIDER_TAG = 'solid-ui-file-explorer-provider' @@ -312,7 +313,7 @@ export default function (context) { subjectUri: subject.uri, pane, relevantPanes: provider.relevantPanes || [], - header: state && explorerHeaderForPane(state.showHeader, pane?.name) + header: state && explorerHeaderForPane(state.showHeader, pane?.name, state.options.folderView) ? { paneIcon: getPaneIcon(pane, subject, context), menuItems: state.menuItems } : undefined, paneElement @@ -380,12 +381,12 @@ export default function (context) { : await loadRelevantPanes(subject, context) if (explorerStates.get(provider) !== state) return provider - // Resolve indexed containers only for a resource URL, never for an explicit Storage view. + // Resolve indexed containers only for a resource URL, never for an explicit storage or folder view. const resourceLogic = context.session.logic?.resource || solidLogicSingleton.resource const indexDocument = subject.uri.endsWith('/') ? $rdf.sym(`${subject.uri}index.ttl`) : null - const hasIndexDocument = options.solo && !requiredPane && indexDocument && ( + const hasIndexDocument = options.solo && !requiredPane && !options.folderView && indexDocument && ( (typeof resourceLogic?.containerHasIndexDocument === 'function' && resourceLogic.containerHasIndexDocument(subject)) || kb.holds(subject, UI.ns.ldp('contains'), indexDocument, subject.doc()) @@ -1515,8 +1516,16 @@ export default function (context) { let subject = kb.canon(subject1) // var requTerm = subject.uri ? kb.sym($rdf.uri.docpart(subject.uri)) : subject + // A solo view the viewer may not read shows only its access error, never a fallback view. + let soloAccessDenied = false function render () { + if (soloAccessDenied) { + // A denial seen before the session was ready is lifted once the document loads. + if (sf.getState(subject.doc().uri) !== 'fetched') return false + soloAccessDenied = false + already = false + } subject = kb.canon(subject) if (!p || !p.parentNode || !p.parentNode.parentNode) return false @@ -1610,6 +1619,12 @@ export default function (context) { if (options.solo) { setUrlBarAndTitle(subject) } + // Global panes render their own application view and do not depend on the page URL. + // Do not let an inaccessible URL replace a dashboard or other global pane with an error. + if (pane?.global) { + render() + return + } UI.log.debug('outlineExpand: dereferencing ' + subject) const status = dom.createElement('span') p.appendChild(status) @@ -1650,12 +1665,49 @@ export default function (context) { if (detailText.includes('status: 401') || detailText.includes('status 401')) return 401 return null } + const soloAccessMessage = function () { + const isRootResource = !!(subject && subject.uri && subject.site && subject.site().uri === subject.uri) + const loggedIn = !!solidLogicSingleton.authn.currentUser() + if (isRootResource) { + return loggedIn + ? 'You are logged in, but this root resource is not publicly readable. Select a storage location from the account menu to continue.' + : 'This root resource is not publicly readable. Try logging in or opening a profile document.' + } + return loggedIn + ? 'You are logged in, but your account does not have permission to view this resource.' + : 'This resource is not publicly readable. Log in to view it.' + } + const showSoloAccessError = function () { + soloAccessDenied = true + const errorState = dom.createElement('solid-ui-error-state') + errorState.setAttribute('title', 'Access denied') + errorState.setAttribute('message', soloAccessMessage()) + if (docUri) errorState.setAttribute('data-outline-error-for', docUri) + UI.utils.emptyNode(p).appendChild(errorState) + sf.addCallback('done', function soloAccessRecovered (uri) { + if (!soloAccessDenied || !p.isConnected) return false + if (uri !== docUri) return true + render() + return false + }) + if (sf.getState(docUri) === 'fetched') render() + } const appendOutlineError = function (detail, errObj) { if (p.querySelector && docUri) { const existing = p.querySelector('[data-outline-error-for="' + docUri + '"]') if (existing) return } const statusCode = authErrorStatus(detail, errObj) + if (statusCode && options.solo) { + // The denial may answer a request made before the session was restored. + if (!retriedAuthenticated && solidLogicSingleton.authn.currentUser()) { + retriedAuthenticated = true + loadSubjectDocument(true) + return + } + showSoloAccessError() + return + } const friendlyDetail = statusCode ? outlineAuthMessage(statusCode) : detail @@ -1669,28 +1721,34 @@ export default function (context) { p.appendChild(message) } // Wait till at least the main URI is loaded before expanding: - let errorReported = false - const fetchPromise = sf.nowOrWhenFetched(subject.doc(), undefined, function (ok, body) { - if (ok) { - sf.lookUpThing(subject) - render() // inital open, or else full if re-open - if (options.solo) { - // Update window title with new information - // dom.title = UI.utils.label(subject) - setUrlBarAndTitle(subject) + let retriedAuthenticated = false + const loadSubjectDocument = function (force) { + let errorReported = false + const fetchPromise = sf.nowOrWhenFetched(subject.doc(), force ? { force: true } : {}, function (ok, body) { + if (ok) { + sf.lookUpThing(subject) + render() // inital open, or else full if re-open + if (options.solo) { + // Update window title with new information + // dom.title = UI.utils.label(subject) + setUrlBarAndTitle(subject) + } + } else { + errorReported = true + appendOutlineError(body, undefined) } - } else { - errorReported = true - appendOutlineError(body, undefined) - } - }) - if (fetchPromise && typeof fetchPromise.catch === 'function') { - fetchPromise.catch(function (err) { - if (errorReported) return - errorReported = true - appendOutlineError(err, err) }) + if (fetchPromise && typeof fetchPromise.catch === 'function') { + fetchPromise.catch(function (err) { + if (errorReported) return + errorReported = true + appendOutlineError(err, err) + }) + } } + // A solo view retries a document an earlier, possibly unauthenticated, request was denied. + const previousStatus = sf.requested[docUri] + loadSubjectDocument(!!options.solo && (previousStatus === 401 || previousStatus === 403)) } else { render() } @@ -1835,6 +1893,7 @@ export default function (context) { @param options -- optional -- who asked for the view: `{ source: 'accountMenu' }` for the viewer's own content, which suppresses the automatic navbar reveal and is remembered in the history entry. The old form, `false` for the account menu, is still accepted. + `folderView` ('explorer', 'storage' or 'container') says how a folder is presented. Afterwards, when `solo`, `solid-panes:navigated` is dispatched on the document. */ @@ -1875,6 +1934,7 @@ export default function (context) { outlineExpand(subjectBlock, displayedSubject, { pane, solo, + folderView: resolveFolderView(options), headerOnlyInViewerPod: !showNavbar || !!(pane && pane.subject && pane.subject.uri) }) } else if (!solo) { diff --git a/test/unit/mainPage/index.test.ts b/test/unit/mainPage/index.test.ts index 59b3c12c..52a841a0 100644 --- a/test/unit/mainPage/index.test.ts +++ b/test/unit/mainPage/index.test.ts @@ -304,7 +304,7 @@ describe('browser navigation without a reload', () => { const [subject, , pane, , , , options] = GotoSubject.mock.calls[0] expect(subject.value).toBe(pageUrl) expect(pane).toBe(storagePane) - expect(options).toEqual({ source: 'accountMenu' }) + expect(options).toEqual({ source: 'accountMenu', folderView: 'explorer' }) expect(window.location.href).toBe(pageUrl) expect(window.history.state).toMatchObject({ paneName: 'folder', paneUri: storage, viaAccountMenu: true }) expect(navbar).toHaveClass('navbar--hidden') diff --git a/test/unit/mainPage/profileHeadingFriends.test.ts b/test/unit/mainPage/profileHeadingFriends.test.ts index 5ef31ae5..b1d38c66 100644 --- a/test/unit/mainPage/profileHeadingFriends.test.ts +++ b/test/unit/mainPage/profileHeadingFriends.test.ts @@ -234,6 +234,20 @@ describe('profile heading friends', () => { expect(heading.friendState).toBe('available') }) + it('keeps the friend action but hides Edit in the compact heading', async () => { + const heading = await createHeading() + heading.editContext = { + subject, + profileData: { name: 'Friend Test', entryNode: subject }, + viewerMode: 'owner', + onSaved: vi.fn() + } + heading.compact = true + await heading.updateComplete + expect(button(heading)).not.toBeNull() + expect(heading.shadowRoot?.querySelector('.desktop-edit-button')).toBeNull() + }) + it('places friends before Edit when both actions are provided', async () => { const heading = await createHeading() heading.editContext = { @@ -251,7 +265,8 @@ describe('profile heading friends', () => { ['profile', viewer, subject, true], ['profile', viewer, viewer, false], ['profile', null, subject, false], - ['social', viewer, subject, false], + ['social', viewer, subject, true], + ['social', viewer, viewer, false], ['profile', viewer, sym('https://new-friend.example/#me'), true] ])('gates the action for pane %s, viewer %s, subject %s', async (paneName, me, viewed, visible) => { vi.mocked(authn.currentUser).mockReturnValue(me) diff --git a/test/unit/navigation/chrome.test.ts b/test/unit/navigation/chrome.test.ts index 944c0a29..0c2e16fa 100644 --- a/test/unit/navigation/chrome.test.ts +++ b/test/unit/navigation/chrome.test.ts @@ -3,10 +3,12 @@ import { deriveExplorerHeader, deriveHeading, deriveNavbarSelection, + deriveStorageView, explorerHeaderForPane, isNavbarHidden, isNavbarItemSelected, isPersonPane, + offersFriendAction, pickNavbarItem, type HeadingInput } from '../../../src/navigation/chrome' @@ -39,6 +41,14 @@ describe('isPersonPane', () => { ) }) +describe('offersFriendAction', () => { + it.each([['profile', true], ['social', true], ['folder', false], [undefined, false]])( + 'the heading over %s offers the friend action: %s', (pane, expected) => { + expect(offersFriendAction(pane)).toBe(expected) + } + ) +}) + describe('deriveHeading', () => { describe('profile and friends panes present their own subject', () => { it('is full for the profile pane and compact for friends', () => { @@ -205,4 +215,30 @@ describe('file explorer header', () => { expect(explorerHeaderForPane(true, undefined)).toBe(true) expect(explorerHeaderForPane(false, 'folder')).toBe(false) }) + + it('is never shown over a storage view, which has its own header or the navbar', () => { + expect(explorerHeaderForPane(true, 'folder', 'explorer')).toBe(false) + expect(explorerHeaderForPane(true, 'folder', 'storage')).toBe(false) + expect(explorerHeaderForPane(true, 'folder', 'container')).toBe(true) + // A pane opened in place of the folder keeps its resource header + expect(explorerHeaderForPane(true, 'source', 'explorer')).toBe(true) + }) +}) + +describe('storage view', () => { + it('shows the viewer\'s own storage with the storage header', () => { + expect(deriveStorageView({ viaAccountMenu: false, inViewerPod: true, hasOwner: true })).toBe('explorer') + }) + + it('shows the storage header for the account menu, whatever the page', () => { + expect(deriveStorageView({ viaAccountMenu: true, inViewerPod: false, hasOwner: true })).toBe('explorer') + }) + + it('shows the server root, which nobody owns, with the storage header', () => { + expect(deriveStorageView({ viaAccountMenu: false, inViewerPod: false, hasOwner: false })).toBe('explorer') + }) + + it('shows someone else\'s storage with only the sidebar and content, under their navbar', () => { + expect(deriveStorageView({ viaAccountMenu: false, inViewerPod: false, hasOwner: true })).toBe('storage') + }) }) diff --git a/test/unit/navigation/controller.test.ts b/test/unit/navigation/controller.test.ts index 914f639d..0bf69eaf 100644 --- a/test/unit/navigation/controller.test.ts +++ b/test/unit/navigation/controller.test.ts @@ -34,6 +34,7 @@ let GotoSubject: Mock<(...args: any[]) => void> let selectNavbarPane: Mock let refreshHeading: Mock let byName: Mock +let podContextOf: Mock> let controller: NavigationController let navbar: HTMLElement @@ -54,11 +55,13 @@ beforeEach(() => { selectNavbarPane = vi.fn(() => { calls.push('select') }) refreshHeading = vi.fn(async () => { calls.push('heading') }) byName = vi.fn((name: string) => (name === 'home' ? homePane as never : null)) + podContextOf = vi.fn(async () => ({ owner, storagePanes: [], viewerStorages: [] })) controller = createNavigationController({ store, outliner: { GotoSubject } as unknown as OutlineManager, byName, - effects: { selectNavbarPane, refreshHeading } + effects: { selectNavbarPane, refreshHeading }, + podContextOf }) navbar = document.createElement('solid-panes-navbar') document.body.appendChild(navbar) @@ -268,3 +271,92 @@ describe('when intents overlap', () => { expect(GotoSubject).toHaveBeenCalledTimes(2) }) }) + +describe('how a storage is presented', () => { + const folderViewOfCall = (index = 0) => GotoSubject.mock.calls[index][6]?.folderView + const storageItem = (uri: string) => ({ name: 'folder', paneName: 'folder', subject: store.sym(uri) }) + + it('shows someone else\'s storage from the navbar with only the sidebar and content', async () => { + await controller.show({ kind: 'storage', pane: storagePane as never }) + + expect(podContextOf).toHaveBeenCalledWith('https://pod.example/timea/') + expect(GotoSubject.mock.calls[0][6]).toEqual({ source: 'default', folderView: 'storage' }) + }) + + it('shows a storage from the account menu with the storage header, without looking up its pod', async () => { + await controller.show({ kind: 'storage', pane: storagePane as never, source: 'accountMenu' }) + + expect(podContextOf).not.toHaveBeenCalled() + expect(folderViewOfCall()).toBe('explorer') + }) + + it('shows someone else\'s pod root as their storage, selected in the navbar', async () => { + const root = `${window.location.origin}/timea/` + const rootPane = storageItem(root) + getFolderPane.mockResolvedValue(rootPane) + podContextOf.mockResolvedValue({ owner, storagePanes: [rootPane as never], viewerStorages: [] }) + window.history.replaceState({}, '', '/timea/') + + await controller.show({ kind: 'restore' }) + + expect(getFolderPane).toHaveBeenCalledWith(store.sym(root)) + expect(GotoSubject.mock.calls[0][2]).toBe(rootPane) + expect(folderViewOfCall()).toBe('storage') + expect(selectNavbarPane).toHaveBeenCalledWith('folder', root) + }) + + it('shows the viewer\'s own pod root with the storage header', async () => { + const root = `${window.location.origin}/timea/` + const rootPane = storageItem(root) + getFolderPane.mockResolvedValue(rootPane) + podContextOf.mockResolvedValue({ owner, viewer: owner, storagePanes: [rootPane as never], viewerStorages: [root] }) + window.history.replaceState({}, '', '/timea/') + + await controller.show({ kind: 'restore' }) + + expect(folderViewOfCall()).toBe('explorer') + }) + + it('recognizes the viewer\'s exact storage URL when it is not listed as a storage pane', async () => { + const root = `${window.location.origin}/timea/` + const rootPane = storageItem(root) + getFolderPane.mockResolvedValue(rootPane) + podContextOf.mockResolvedValue({ owner, viewer: owner, storagePanes: [], viewerStorages: [root] }) + window.history.replaceState({}, '', '/timea/') + + await controller.show({ kind: 'restore' }) + + expect(folderViewOfCall()).toBe('explorer') + }) + + it('does not treat a subcontainer of the viewer\'s storage as the storage itself', async () => { + const root = `${window.location.origin}/timea/` + const subcontainer = `${root}documents/` + getFolderPane.mockResolvedValue(storageItem(subcontainer)) + podContextOf.mockResolvedValue({ owner, viewer: owner, storagePanes: [], viewerStorages: [root] }) + window.history.replaceState({}, '', '/timea/documents/') + + await controller.show({ kind: 'restore' }) + + expect(folderViewOfCall()).toBeUndefined() + }) + + it('shows the server root, which nobody owns, with the storage header', async () => { + const root = `${window.location.origin}/` + getFolderPane.mockResolvedValue(storageItem(root)) + podContextOf.mockResolvedValue({ storagePanes: [], viewerStorages: [] }) + window.history.replaceState({}, '', '/') + + await controller.show({ kind: 'restore' }) + + expect(getFolderPane).toHaveBeenCalledWith(store.sym(root)) + expect(folderViewOfCall()).toBe('explorer') + }) + + it('leaves a container that is not a storage to its own default view', async () => { + await controller.show({ kind: 'restore' }) + + expect(getFolderPane).not.toHaveBeenCalled() + expect(GotoSubject.mock.calls[0][6]).toEqual({ source: 'default' }) + }) +}) diff --git a/test/unit/navigation/podContext.test.ts b/test/unit/navigation/podContext.test.ts index 4d03f0bf..4603f00d 100644 --- a/test/unit/navigation/podContext.test.ts +++ b/test/unit/navigation/podContext.test.ts @@ -52,6 +52,7 @@ describe('getPodContext', () => { viewerStorages: ['https://pod.example/other/'] }) expect(context.storagePanes.map(pane => pane.subject.value)).toEqual(['https://pod.example/timea/']) + expect(getFolderPanes).toHaveBeenCalledWith(owner) }) it('looks up the storages of a pod and of the viewer once, however many of its pages are asked about', async () => { diff --git a/test/unit/navigation/storagePane.test.ts b/test/unit/navigation/storagePane.test.ts index 6579605a..e07a6a20 100644 --- a/test/unit/navigation/storagePane.test.ts +++ b/test/unit/navigation/storagePane.test.ts @@ -14,6 +14,16 @@ describe('explicit Storage navigation', () => { expect(render).toHaveBeenCalledWith(storage, context, { solo: false, fullView: false }) }) + it('passes on the folder view the navigation chose', () => { + const render = vi.fn() + const storage = sym('https://pod.example/storage/') + const pane = createPaneItem({ name: 'folder', render }, storage, 'folder', 'Storage', '') + + pane.render(storage, {}, { solo: true, folderView: 'storage' }) + + expect(render).toHaveBeenCalledWith(storage, {}, { solo: false, fullView: false, folderView: 'storage' }) + }) + it('preserves solo rendering options for non-storage panes', () => { const render = vi.fn() const subject = sym('https://pod.example/profile/card#me') From 1b51249fec8ee75015e1bdca62870b5a605ca5ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 25 Oct 2026 20:05:47 +0000 Subject: [PATCH 2/2] chore: update solidos dependencies (dev: solid-logic@6.0.1-4 solid-ui@5.0.2-3 pane-registry@5.0.2-1 folder-pane@4.0.1-1 profile-pane@4.1.0-0 source-pane@4.0.1-0) (latest: rdflib@2.4.1 activitystreams-pane@2.0.0 chat-pane@4.0.0 contacts-pane@4.0.0 issue-pane@4.0.0 meeting-pane@4.0.0) --- package-lock.json | 68 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7f2bce31..754d12d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "chat-pane": "4.0.0", "contacts-pane": "4.0.0", "dompurify": "^3.4.4", - "folder-pane": "4.0.1-0", + "folder-pane": "4.0.1-1", "issue-pane": "4.0.0", "lit-html": "^3.3.2", "marked": "^18.0.3", @@ -522,9 +522,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", "devOptional": true, "license": "MIT", "engines": { @@ -581,13 +581,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.8" + "@babel/types": "^7.29.11" }, "bin": { "parser": "bin/babel-parser.js" @@ -894,9 +894,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", - "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.11.tgz", + "integrity": "sha512-iARF4jjHSM0v8DImCACPXrwEltBP3DgqDpXTvjAMBAVpyEY0LiGss78wvI+KtmW+IJNEvZ5sOhPDCsOsXsIybA==", "dev": true, "license": "MIT", "dependencies": { @@ -944,9 +944,9 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", - "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.11.tgz", + "integrity": "sha512-f8kTDM/nTvpLeafb+TJ4jboFr/Y3BbC+QzgPpPGzSKJqWLnj9LW0WLqv4XKA11LX5sr+EgvLE6Ix3YsCEusodw==", "dev": true, "license": "MIT", "dependencies": { @@ -955,7 +955,7 @@ "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/traverse": "^7.29.10" }, "engines": { "node": ">=6.9.0" @@ -1581,9 +1581,9 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", - "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.11.tgz", + "integrity": "sha512-5xWGhfuMgbOfIjsk/I2c4uMnoqRwQdKYi4QjvybQrcEVE0G9/p3qmIkp2Sxe8YF8DGNNVs5whfwNqIM5684XoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1921,13 +1921,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { @@ -3649,9 +3649,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.13.tgz", - "integrity": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", "dev": true, "license": "MIT", "engines": { @@ -4554,9 +4554,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.31.tgz", - "integrity": "sha512-b0k7wXlWjpuL/Uxb/pYlpjY0ej5BjAmurtPUk3eRGuloI4yjK32m8TiKUQ81l1ptcLjKS/KH2+uqIolZti1x4Q==", + "version": "2.11.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.33.tgz", + "integrity": "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5337,9 +5337,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.463", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.463.tgz", - "integrity": "sha512-nVWNU6ltFjopvOdD44J4zoBkgO0M+WZiFKKFV7EjebOH9z0KNphk6bCEjplXtJofL21ARIuuwWOCsFkBVklm6w==", + "version": "1.5.464", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", + "integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==", "dev": true, "license": "ISC" }, @@ -6145,9 +6145,9 @@ "license": "ISC" }, "node_modules/folder-pane": { - "version": "4.0.1-0", - "resolved": "https://registry.npmjs.org/folder-pane/-/folder-pane-4.0.1-0.tgz", - "integrity": "sha512-BbgIQa/2QX9oCKK5wxIlKXdsazyIMxQRJMtYHEe3I4kzE/udzKS7SjN5mQsR2E/yrTu3WC7l1OEcYBzivqEi2A==", + "version": "4.0.1-1", + "resolved": "https://registry.npmjs.org/folder-pane/-/folder-pane-4.0.1-1.tgz", + "integrity": "sha512-cQSKnPUYPhDGHpK7ayUFYCeDpHoJ3XgMUPjig2Xx9YYYLye5G0RVzMGMdNi8utRCFkR46mHyMu/XNNazuOBOcw==", "license": "MIT", "dependencies": { "lit": "^3.3.3", diff --git a/package.json b/package.json index 0efc3c66..7a501c5a 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "chat-pane": "4.0.0", "contacts-pane": "4.0.0", "dompurify": "^3.4.4", - "folder-pane": "4.0.1-0", + "folder-pane": "4.0.1-1", "issue-pane": "4.0.0", "lit-html": "^3.3.2", "marked": "^18.0.3",