feat: open a files link on the folder and file it names

This commit is contained in:
Linus Rath
2026-10-31 05:29:46 +01:00
parent cad9296a84
commit d684b00e9b
4 changed files with 158 additions and 3 deletions
@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { handleDeepLink, parseDeepLink } from '../linking';
import { usePendingFilesLink } from '../pending-files-link';
// `/files/<folder>` and `?preview=` links opened only the Files tab (file 10
// "deep links" on the Files row, 07 "Deep link /files/<folder> and ?preview=
// not handled"). Webmail lib/deep-links.ts parseFilesPath.
const nav = () => ({ isReady: () => true, navigate: vi.fn() });
describe('files deep links', () => {
it('parses the folder path, the file to preview and a shared node', () => {
expect(parseDeepLink('bulwarkmobile://files')).toEqual({ kind: 'files' });
expect(parseDeepLink('bulwarkmobile://files/Docs/Q%203'))
.toEqual({ kind: 'files', path: '/Docs/Q 3' });
expect(parseDeepLink('https://mail.example.com/de/files/Docs?preview=a%20b.pdf'))
.toEqual({ kind: 'files', path: '/Docs', preview: 'a b.pdf' });
expect(parseDeepLink('https://mail.example.com/files?preview=top.txt'))
.toEqual({ kind: 'files', path: '/', preview: 'top.txt' });
expect(parseDeepLink('bulwarkmobile://files?shared=acc-2%3An1&preview=x.txt'))
.toEqual({ kind: 'files', path: '/', shared: 'acc-2:n1', preview: 'x.txt' });
});
it('parks the link for the Files tab', async () => {
const navigation = nav();
await handleDeepLink(parseDeepLink('bulwarkmobile://files/Docs?preview=a.pdf')!, {
navigation: navigation as never, resolveThreadId: async () => null,
});
expect(usePendingFilesLink.getState().consume()).toEqual({ path: '/Docs', preview: 'a.pdf' });
expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Files' });
await handleDeepLink(parseDeepLink('bulwarkmobile://files')!, {
navigation: navigation as never, resolveThreadId: async () => null,
});
expect(usePendingFilesLink.getState().consume()).toBeNull();
});
// No RN renderer in the test env: check the screen's wiring instead.
it('is followed by the Files tab once the drive is listed', () => {
const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/FilesScreen.tsx'), 'utf8');
expect(screen).toMatch(/if \(!pendingFilesLink \|\| loading \|\| !driveRef\.current\?\.account\) return;\s*const link = usePendingFilesLink\.getState\(\)\.consume\(\);/);
expect(screen).toMatch(/stack = resolveFolderPath\(driveNodes\.filter\(\(n\) => !n\.isShared\), link\.path\);/);
expect(screen).toMatch(/listFolder\(driveNodes, parentId\)\.find\(\(n\) => n\.name === link\.preview && !isFolder\(n\)\)/);
expect(screen).toMatch(/if \(row && !isSymlink\(row\) && canPreviewInApp\(row\)\) openPreview\(row\);/);
});
});
+26 -3
View File
@@ -16,6 +16,7 @@ import {
} from './pending-calendar-date';
import { setPendingMailSearch } from './pending-mail-search';
import { setPendingMailFolder } from './pending-mail-folder';
import { setPendingFilesLink } from './pending-files-link';
import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link';
import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth';
import { MAX_ACCOUNTS } from '../lib/account-utils';
@@ -52,7 +53,10 @@ export type DeepLink =
// `/contacts/new`, with `?email=`/`?addEmail=` and `?name=`/`?addName=`.
| { kind: 'contact-new'; email?: string; name?: string }
| { kind: 'contacts' }
| { kind: 'files' }
// `path`: a folder (`/files/<folder>/<sub>`); `preview`: a file in it to
// open (`?preview=`); `shared`: a node of a drive shared with the user
// (`?shared=<owner>:<id>`). None of them: just the Files tab.
| { kind: 'files'; path?: string; preview?: string; shared?: string }
| { kind: 'settings'; tab?: string }
| { kind: 'compose'; to: EmailAddress[]; cc: EmailAddress[]; bcc?: EmailAddress[]; subject?: string; body?: string };
@@ -191,8 +195,19 @@ export function parseDeepLink(url: string): DeepLink | null {
}
return { kind: 'contacts' };
}
case 'files':
return { kind: 'files' };
case 'files': {
// Webmail parseFilesPath.
const parts = segments.slice(1).map(decodeSegment).filter(Boolean);
const preview = search.get('preview') || undefined;
const shared = search.get('shared') || undefined;
if (parts.length === 0 && !preview && !shared) return { kind: 'files' };
return {
kind: 'files',
path: `/${parts.join('/')}`,
...(preview ? { preview } : {}),
...(shared ? { shared } : {}),
};
}
case 'settings':
return { kind: 'settings', tab: kind ? decodeSegment(kind) : undefined };
case 'compose': {
@@ -355,6 +370,14 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr
navigation.navigate('MainTabs', { screen: 'Contacts' } as never);
return true;
case 'files':
// The Files tab walks to the folder once it has listed the drive.
if (link.path || link.preview || link.shared) {
setPendingFilesLink({
path: link.path ?? '/',
...(link.preview ? { preview: link.preview } : {}),
...(link.shared ? { shared: link.shared } : {}),
});
}
navigation.navigate('MainTabs', { screen: 'Files' } as never);
return true;
case 'settings':
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand';
/** A `/files/<folder path>[?preview=<name>]` link (webmail FilesDeepLink). */
export interface FilesLink {
/** Folder path in the drive, leading-slashed (`/` for the root). */
path: string;
/** File name to preview once the folder is open. */
preview?: string;
/**
* A node of a drive shared with the user, by the id the Files tab lists it
* under (`<owner>:<id>`): it has no path under the user's own root.
*/
shared?: string;
}
// A files link arrives before the Files tab is mounted or has listed the
// drive. It is parked here and consumed by FilesScreen once it can walk the
// drive, like pending-mail-folder.
interface PendingFilesLinkState {
link: FilesLink | null;
set: (link: FilesLink | null) => void;
consume: () => FilesLink | null;
}
export const usePendingFilesLink = create<PendingFilesLinkState>((set, get) => ({
link: null,
set: (link) => set({ link }),
consume: () => {
const link = get().link;
if (link) set({ link: null });
return link;
},
}));
export function setPendingFilesLink(link: FilesLink | null): void {
usePendingFilesLink.getState().set(link);
}
+47
View File
@@ -89,6 +89,7 @@ import {
RECENT_FILES_SHOWN, folderPathOf, nodePlace, resolveFolderPath, type FolderStep,
} from '../lib/files-places';
import { NO_FILES_PLACES, useFilesPlacesStore } from '../stores/files-places-store';
import { usePendingFilesLink, type FilesLink } from '../navigation/pending-files-link';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
// A file row carries the display name alongside the rest of the node.
@@ -1409,6 +1410,52 @@ export default function FilesScreen() {
</View>
) : null;
// A `/files/<folder>?preview=<name>` link (webmail files-app): once the
// drive is listed, walk to the folder and open the file there. A link into
// a shared drive names the node by id (`?shared=`).
const applyFilesLink = (link: FilesLink) => {
let stack: FolderStep[] | null = null;
let fileNode: FileNode | undefined;
if (link.shared) {
const place = nodePlace(driveNodes, link.shared);
if (place && isFolder(place.node)) stack = [...place.stack, { id: place.node.id, name: place.node.name }];
else if (place) {
stack = place.stack;
fileNode = place.node;
}
} else if (link.path === `/${TRASH_FOLDER_NAME}`) {
setPath([]);
setInTrash(true);
setSearchQuery('');
clearSelection();
return;
} else {
stack = resolveFolderPath(driveNodes.filter((n) => !n.isShared), link.path);
}
if (!stack) {
toast.error(link.preview
? t('deep_link.file_not_found', 'This file is no longer available.')
: t('deep_link.folder_not_found', 'This folder is no longer available.'));
return;
}
openFolderStack(stack);
if (!link.preview && !fileNode) return;
const parentId = stack.length > 0 ? stack[stack.length - 1].id : null;
const target = fileNode ?? listFolder(driveNodes, parentId).find((n) => n.name === link.preview && !isFolder(n));
const row = target ? { ...target, displayName: target.name } : null;
if (row && !isSymlink(row) && canPreviewInApp(row)) openPreview(row);
else toast.error(t('deep_link.file_not_found', 'This file is no longer available.'));
};
const applyFilesLinkRef = useRef(applyFilesLink);
applyFilesLinkRef.current = applyFilesLink;
const pendingFilesLink = usePendingFilesLink((s) => s.link);
useEffect(() => {
// Wait for the listing: the walk needs the drive's folders.
if (!pendingFilesLink || loading || !driveRef.current?.account) return;
const link = usePendingFilesLink.getState().consume();
if (link) applyFilesLinkRef.current(link);
}, [pendingFilesLink, loading]);
let body: React.ReactNode;
if (loading && !refreshing) {
body = (