diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx
index d5bcda6..4d3f0e0 100644
--- a/frontend/src/detail/DetailPanel.jsx
+++ b/frontend/src/detail/DetailPanel.jsx
@@ -323,6 +323,8 @@ const DetailPanel = ({
onCorrespondentAdd,
onCorrespondentRemove,
resolveApiPath,
+ onFolderNavigate = null,
+ resolveFolderPath = null,
onClose = () => {},
}) => {
const selectedCount = selectedDocuments.length;
@@ -670,6 +672,20 @@ const DetailPanel = ({
return sortCorrespondents(singleDoc.correspondents || []);
}, [singleDoc]);
+ const singleFolderPath = useMemo(() => {
+ if (!singleDoc?.folder_id) {
+ return null;
+ }
+ if (typeof resolveFolderPath !== 'function') {
+ return null;
+ }
+ const segments = resolveFolderPath(singleDoc.folder_id);
+ if (!Array.isArray(segments) || !segments.some((segment) => segment?.id && segment.id !== 'root')) {
+ return null;
+ }
+ return segments;
+ }, [singleDoc?.folder_id, resolveFolderPath]);
+
const bulkCorrespondents = useMemo(() => {
if (selectedDocuments.length <= 1) {
const doc = selectedDocuments[0];
@@ -1063,6 +1079,53 @@ const DetailPanel = ({
Pages: {pageCountValue}
) : null}
+ {singleFolderPath?.length ? (
+
+ ) : null}
Original filename:{' '}
{singleDoc.original_name}
diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx
index ad0e5d3..210e146 100644
--- a/frontend/src/index.jsx
+++ b/frontend/src/index.jsx
@@ -594,6 +594,7 @@ const AppLayout = () => {
const selectionInitializedRef = useRef(false);
const dragCounterRef = useRef(0);
const prefetchedFoldersRef = useRef(new Set(['root']));
+ const detailFolderFetchRef = useRef(new Set());
const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null);
const selectionOrderRef = useRef(initialRowSelection);
@@ -643,6 +644,7 @@ const AppLayout = () => {
dragCounterRef.current = 0;
prefetchedFoldersRef.current = new Set(['root']);
breadcrumbFetchRef.current = new Set();
+ detailFolderFetchRef.current = new Set();
bootstrapInitializedRef.current = false;
selectionInitializedRef.current = false;
}, [assetManager]);
@@ -1868,19 +1870,134 @@ const AppLayout = () => {
greedyPrefetchFolders(pendingIds).catch(() => {});
}, [folderContents, greedyPrefetchFolders]);
+ const expandFolderAncestors = useCallback(
+ (targetId) => {
+ if (!targetId || targetId === 'root') {
+ setFolderNodes((prev) => {
+ if (prev.get('root')?.expanded) {
+ return prev;
+ }
+ const next = new Map(prev);
+ const rootNode = next.get('root');
+ if (rootNode) {
+ next.set('root', { ...rootNode, expanded: true });
+ }
+ return next;
+ });
+ return;
+ }
+
+ setFolderNodes((prev) => {
+ const next = new Map(prev);
+ let currentId = targetId;
+ let guard = 0;
+ while (currentId && !next.has(currentId) && guard < 32) {
+ guard += 1;
+ const node = prev.get(currentId);
+ if (!node) {
+ break;
+ }
+ currentId = node.parentId ?? 'root';
+ }
+
+ currentId = targetId;
+ guard = 0;
+ while (currentId && guard < 32) {
+ guard += 1;
+ const node = next.get(currentId);
+ if (!node) {
+ break;
+ }
+ if (!node.expanded && currentId !== targetId) {
+ next.set(currentId, { ...node, expanded: true });
+ }
+ currentId = node.parentId ?? 'root';
+ if (!currentId || currentId === 'root') {
+ const rootNode = next.get('root');
+ if (rootNode && !rootNode.expanded) {
+ next.set('root', { ...rootNode, expanded: true });
+ }
+ break;
+ }
+ }
+ return next;
+ });
+ },
+ []);
+
+ const ensureFolderAncestorsLoaded = useCallback(
+ async (targetId) => {
+ if (!targetId || targetId === 'root') {
+ return;
+ }
+
+ const visited = new Set();
+ const stack = [];
+
+ let currentId = targetId;
+ let guard = 0;
+ while (currentId && currentId !== 'root' && guard < 32) {
+ guard += 1;
+ if (visited.has(currentId)) {
+ break;
+ }
+ visited.add(currentId);
+
+ const node = folderNodes.get(currentId);
+ if (!node) {
+ stack.push(currentId);
+ break;
+ }
+
+ const parentId = node.parentId ?? 'root';
+ if (!parentId || parentId === 'root') {
+ break;
+ }
+ stack.push(parentId);
+ currentId = parentId;
+ }
+
+ while (stack.length) {
+ const ancestorId = stack.pop();
+ if (!ancestorId || ancestorId === 'root') {
+ continue;
+ }
+ if (!folderNodes.has(ancestorId)) {
+ try {
+ await ensureFolderData(ancestorId, { includeDocuments: false, force: false });
+ } catch (error) {
+ console.warn('Failed to ensure ancestor folder for navigation', ancestorId, error);
+ }
+ }
+ }
+ },
+ [folderNodes, ensureFolderData],
+ );
+
const selectFolder = useCallback(
async (folderId, { replace = false, immediate = false } = {}) => {
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
+ await ensureFolderAncestorsLoaded(targetId);
+ expandFolderAncestors(targetId);
+
if (!navigate || immediate) {
await loadFolder(targetId, { preserveSearch: isFilterActive });
+ setSelectedFolder(targetId);
return;
}
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
navigate(path, { replace });
},
- [loadFolder, navigate, isFilterActive],
+ [
+ ensureFolderAncestorsLoaded,
+ expandFolderAncestors,
+ navigate,
+ loadFolder,
+ isFilterActive,
+ setSelectedFolder,
+ ],
);
const initializeAfterLogin = useCallback(async () => {
@@ -4450,6 +4567,95 @@ const AppLayout = () => {
});
}, [missingBreadcrumbAncestors, ensureFolderData]);
+ useEffect(() => {
+ if (!orderedSelectedDocuments.length) {
+ return;
+ }
+
+ const visited = new Set();
+
+ orderedSelectedDocuments.forEach((doc) => {
+ const folderId = doc?.folder_id;
+ if (!folderId) {
+ return;
+ }
+ let currentId = folderId;
+ let guard = 0;
+ while (currentId && currentId !== 'root' && guard < 32) {
+ guard += 1;
+ if (visited.has(currentId)) {
+ break;
+ }
+ visited.add(currentId);
+ const node = folderNodes.get(currentId);
+ if (!node) {
+ if (!detailFolderFetchRef.current.has(currentId)) {
+ detailFolderFetchRef.current.add(currentId);
+ ensureFolderData(currentId, { force: false, includeDocuments: false })
+ .catch((error) => {
+ console.warn('Failed to preload folder metadata for detail path', currentId, error);
+ })
+ .finally(() => {
+ detailFolderFetchRef.current.delete(currentId);
+ });
+ }
+ break;
+ }
+
+ const parentId = node.parentId ?? 'root';
+ if (!parentId || parentId === 'root') {
+ break;
+ }
+ currentId = parentId;
+ }
+ });
+ }, [orderedSelectedDocuments, folderNodes, ensureFolderData]);
+
+ const resolveFolderPath = useCallback(
+ (folderId) => {
+ if (!folderId || folderId === 'root') {
+ return [];
+ }
+
+ const segments = [];
+ const visited = new Set();
+ let currentId = folderId;
+ let guard = 0;
+
+ while (currentId && guard < 32 && !visited.has(currentId)) {
+ guard += 1;
+ visited.add(currentId);
+
+ if (currentId === 'root') {
+ break;
+ }
+
+ const node = folderNodes.get(currentId);
+ if (!node) {
+ segments.push({ id: currentId, name: '…' });
+ break;
+ }
+
+ segments.push({ id: node.id, name: node.name || 'Folder' });
+
+ const parentId = node.parentId ?? 'root';
+ if (!parentId || parentId === 'root') {
+ segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
+ break;
+ }
+
+ currentId = parentId;
+ }
+
+ if (!segments.some((segment) => segment.id === 'root')) {
+ segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
+ }
+
+ return segments.reverse();
+ },
+ [folderNodes],
+ );
+
const selectedPreviewEntry = useMemo(() => {
if (!selectedDocument) {
return null;
@@ -4598,7 +4804,9 @@ const AppLayout = () => {
onCorrespondentAdd: handleCorrespondentAdd,
onCorrespondentRemove: handleCorrespondentRemove,
resolveApiPath,
+ onFolderNavigate: selectFolder,
onClose: clearDocumentSelection,
+ resolveFolderPath,
};
const skeuoWorkspaceProps = useMemo(
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 34d2565..31420f9 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -71,6 +71,25 @@ body {
overflow: hidden;
}
+a {
+ color: var(--accent);
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 0.18em;
+ transition: color 0.15s ease, text-decoration-color 0.15s ease;
+}
+
+a:visited {
+ color: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%);
+}
+
+a:hover,
+a:focus-visible {
+ color: var(--accent-hover);
+ outline: none;
+ text-decoration-color: currentColor;
+}
+
#app {
height: 100%;
background: var(--bg);
@@ -1821,6 +1840,32 @@ button.danger:hover:not([disabled]) {
color: var(--muted);
}
+.detail-panel .detail-folder-path {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ align-items: center;
+}
+
+.detail-panel .detail-folder-path__link {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.detail-panel .detail-folder-path__link:hover,
+.detail-panel .detail-folder-path__link:focus-visible {
+ text-decoration: underline;
+ outline: none;
+}
+
+.detail-panel .detail-folder-path__separator {
+ color: var(--muted);
+}
+
+.detail-panel .detail-folder-path__segment {
+ color: var(--fg);
+}
+
.detail-panel .doc-title-row {
display: flex;
align-items: center;