import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, getAccountRaw, setAccountRaw } from "../lib/storage";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation, useNavigationActions } from "./navigationContext";
import {
ALL_FILES,
FilesActionsContext,
FilesStateContext,
parseItem,
parseView,
viewParam,
type FilesActions,
type FilesArrival,
type FilesFocus,
type FilesState,
type FilesView,
} from "./filesContext";
// Owns the Files module's shared state — the header search term and the view the rail picked (every
// file, shared with me, or a folder) — so the header's search box, the FilesRail and the Files page
// read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in
// main.tsx around .
//
// The search is ephemeral (resets on reload, like the other list filters); the view is remembered per
// account and mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder.
// A `?folder=` in the address wins over the remembered one. A folder that no longer exists is the
// page's to fall back from (it knows the folder list).
//
// An arrival at one file (`?item=`, or `openFile` from a notice) is an INTENT the page consumes once it
// has the file in view, the way the channel manager's focusChannel is: the consumed marker lives here,
// because the page chunk remounts on every navigation and would otherwise re-apply it. Until then the
// address carries it, so a reload mid-arrival still lands on the file.
function initialView(): FilesView {
return (
parseView(new URLSearchParams(window.location.search).get("folder")) ??
parseView(getAccountRaw(LS.filesView)) ??
ALL_FILES
);
}
function initialFocus(): FilesFocus | null {
const id = parseItem(new URLSearchParams(window.location.search).get("item"));
return id === null ? null : { id, token: 1 };
}
export function FilesProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
const [view, setViewState] = useState(initialView);
const [focusRaw, setFocusRaw] = useState(initialFocus);
const [focusApplied, setFocusApplied] = useState(0);
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setView = useCallback((next: FilesView) => {
setViewState(next);
setAccountRaw(LS.filesView, viewParam(next));
}, []);
const { page } = useNavigation();
const { setPage } = useNavigationActions();
const openFile = useCallback(
(id: number, folderId: number | null) => {
setView(folderId === null ? ALL_FILES : { kind: "folder", id: folderId });
// A search that hides the file would leave nothing to arrive at.
setSearchState("");
setFocusRaw((prev) => ({ id, token: (prev?.token ?? 0) + 1 }));
setPage("files");
},
[setView, setPage],
);
// What the page found. Held HERE, and set only once the page has mounted and looked: the table and
// the grid follow a reveal that CHANGES under them, never one they mount with (a remount must not
// move a page nobody asked to move), so an arrival decided during the page's first render would be
// one they never turn to.
const [arrivedRaw, setArrivedRaw] = useState(null);
const consumeFocus = useCallback(
(at: string | null) => {
setFocusApplied(focusRaw?.token ?? 0);
setArrivedRaw(focusRaw && at !== null ? { ...focusRaw, view: at } : null);
},
[focusRaw],
);
// Only ON the Files page, and only until the page has used it.
const focus = page === "files" && focusRaw && focusRaw.token !== focusApplied ? focusRaw : null;
// Leaving the page ends the arrival: back on it later, nothing is rung or turned to.
if (page !== "files" && arrivedRaw) setArrivedRaw(null);
const arrived = page === "files" ? arrivedRaw : null;
useUrlMirror({
folder: page === "files" ? viewParam(view) : null,
item: focus ? String(focus.id) : null,
});
const state = useMemo(
() => ({ search, view, focus, arrived }),
[search, view, focus, arrived],
);
const actions = useMemo(
() => ({ setSearch, setView, openFile, consumeFocus }),
[setSearch, setView, openFile, consumeFocus],
);
return (
{children}
);
}