import { useEffect, useRef, useState } from "react"; import { Meter } from "./ui/Meter"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import clsx from "clsx"; import { FolderOpen, FolderPlus, HardDrive, Link2, Users } from "lucide-react"; import { api, type FileFolder } from "../lib/api"; import { compareText } from "../lib/collate"; import { formatBytes } from "../lib/format"; import { type PanelLayout } from "../lib/panelLayout"; import { qk } from "../lib/queryKeys"; import { useLiveQuery } from "../lib/useLiveQuery"; import FilterRail from "./FilterRail"; import { folderIcon, folderLabel } from "./fileFolders"; import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext"; import { RenameField } from "./ui/RenameField"; import { useFolderActions } from "./useFolderActions"; import { ContextMenu } from "./ui/ContextMenu"; import { Pressable } from "./ui/Pressable"; import { type PanelItem } from "./ui/PanelGroups"; import { useContextMenu } from "./ui/useContextMenu"; // The Files module's left rail: the library's places — every file, the ones shared with me, the // source folders the library fills by itself, the user's own folders — and the storage meter. Lifted // to App level like the other rails (the SidePanel's collapse and customise come with it); the view it // picks lives in FilesProvider, which the Files page reads. A folder's right click (or long press, or // Shift+F10) opens its menu (`useFolderActions`); the same menu is the "…" beside an open folder's // name on the page, so nothing here is reachable only by a gesture. const folderKey = (f: FileFolder) => f.id; function Entry({ icon: Icon, label, count, view, trigger, folderId, shared = false, }: { icon: React.ComponentType<{ className?: string }>; label: string; count?: number; /** A live public link opens the folder: what is moved in is shown to whoever holds the link. */ shared?: boolean; view: FilesView; /** The folder's menu triggers (right click, long press, Shift+F10). */ trigger?: Record; /** A folder's entry is found by it, to hand the keyboard back after a rename. */ folderId?: number; }) { const { t } = useTranslation(); const { view: current } = useFiles(); const { setView } = useFilesActions(); const active = sameView(view, current); return ( setView(view)} className={clsx( "flex w-full min-h-8 items-center gap-2 rounded-lg px-2 text-sm", active ? "bg-accent/12 font-medium text-accent" : "text-fg", )} > {label} {shared && ( {t("files.folder.shared")} )} {count !== undefined && {count}} ); } function StorageMeter() { const { t, i18n } = useTranslation(); const lang = i18n.language; // Live while something is in flight (a download's bytes land when the worker finishes it), at rest // otherwise — the old Download Center's meter, moved to where the files are. const usage = useLiveQuery(qk.storageUsage(), api.filesUsage, { intervalMs: (u) => (u?.running_jobs ? 2000 : 15000), }); const u = usage.data; // A failed read says so where the figures would be, rather than the meter quietly not being there. if (!u) return usage.isError ? (

{t("files.usage.failed")}

) : null; return (
{t("files.usage.title")} {formatBytes(u.footprint_bytes, lang)} {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`} {!u.unlimited && ( )}
); } export default function FilesRail({ layout, setLayout, collapsed, onToggleCollapse, }: { layout: PanelLayout; setLayout: (l: PanelLayout) => void; collapsed: boolean; onToggleCollapse: () => void; }) { const { t, i18n } = useTranslation(); const { view } = useFiles(); const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders }); const folders = foldersQ.data ?? []; const total = folders.reduce((n, f) => n + f.count, 0); // A source folder its files have all left stays on the server; here it shows only while it holds // something, or while it is the one open (so the page never points at a folder the rail hides). const shown = (f: FileFolder) => f.count > 0 || sameView(view, { kind: "folder", id: f.id }); const byName = (a: FileFolder, b: FileFolder) => compareText(folderLabel(a, t), folderLabel(b, t), i18n.language); const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName); const own = folders.filter((f) => f.kind === "own").sort(byName); // The Hub folder closes the sources, shown even while empty: it exists only once a hub token made // it, and an empty one is where the hub's files will turn up. const hub = folders.filter((f) => f.kind === "hub"); const [renamingId, setRenamingId] = useState(null); const [creating, setCreating] = useState(false); // A name field left by Enter or Escape hands the keyboard back to what stands there now: the // folder's entry, or the New-folder command (RenameField). const railRef = useRef(null); const refocusFolder = useRef(null); useEffect(() => { const id = refocusFolder.current; if (renamingId !== null || id === null) return; refocusFolder.current = null; railRef.current?.querySelector(`[data-folder-id="${id}"]`)?.focus(); }, [renamingId]); const newFolder = useRef(null); const refocusNew = useRef(false); useEffect(() => { if (creating || !refocusNew.current) return; refocusNew.current = false; newFolder.current?.focus(); }, [creating]); const folderActions = useFolderActions({ onRename: (f) => setRenamingId(f.id) }); const menu = useContextMenu({ keyOf: folderKey }); const failed = foldersQ.isError && !foldersQ.data && ( foldersQ.refetch()} className="px-2 py-1 text-xs text-danger hover:underline" > {t("common.loadError")} — {t("common.retry")} ); const entry = (f: FileFolder) => renamingId === f.id ? ( { if (byKey) refocusFolder.current = f.id; setRenamingId(null); if (name !== null) folderActions.rename(f, name); }} /> ) : ( ); const items: PanelItem[] = [ { id: "library", title: t("files.rail.library"), body: (
), }, { id: "sources", title: t("files.rail.sources"), body: (
{failed} {sources.map(entry)} {hub.map(entry)} {/* Only when there is nothing at all: files moved into own folders were still downloaded. */} {foldersQ.data && sources.length + hub.length === 0 && total === 0 && ( {t("files.rail.noSources")} )}
), }, { id: "own", title: t("files.rail.own"), body: (
{failed} {own.map(entry)} {foldersQ.data && own.length === 0 && !creating && ( {t("files.rail.noOwn")} )} {creating ? ( { refocusNew.current = byKey; setCreating(false); if (name !== null) void folderActions.add(name); }} /> ) : ( foldersQ.data && ( setCreating(true)} className="flex min-h-8 w-full items-center gap-2 rounded-lg px-2 text-sm text-muted hover:text-fg" > {t("files.folder.new")} ) )}
), }, ]; return ( } collapsed={collapsed} onToggleCollapse={onToggleCollapse} layout={layout} setLayout={setLayout} items={items} > {folderActions.dialog} ); }