Disk-mounted folders on desktop and improved folder management (#7502)

Description of Changes

Adds folder kinds so the file manager can work with real directories on
disk.

Desktop
- New folder is now a menu with two options: "Add local folder" and "New
folder on the server".
- Add local folder opens the native picker and mounts a directory. Files
are listed straight from disk, nothing is copied in.
- Subfolders show inside a mount and open like any folder. New folder
inside a mount creates a real directory on disk.
- Moving, dropping or uploading files into a mount writes them to the
directory. The app copy is only removed after the write succeeds. Name
clashes get a " (2)" suffix.
- Mounted files get thumbnails.
- Adding the same directory twice just returns the existing mount.
- Removing a mount never touches the disk.
- The server option is disabled in local mode with a sign in message.

Web + desktop
- Uploading or dropping files while inside a folder puts them in that
folder instead of Local.
- Files can be dragged onto folders in the grid and the tree to move
them.
- Folders show an origin badge (cloud or local).
- The Local view now means files that are not in any folder.

Follow ups for a future pr
- Mount listing cap: large directories currently show the 500 most
recent files with no notice. Will be removed as part of the
virtualisation/performance PR.
- Folders within folders need to be supported
- Symlinks in mounts: currently not listed. Behaviour to be decided
alongside the wider folder work.
This commit is contained in:
Reece Browne
2026-09-02 14:18:57 +00:00
committed by GitHub
parent 3056e5ff44
commit 1b2a3118a6
37 changed files with 2596 additions and 170 deletions
+1
View File
@@ -312,3 +312,4 @@ docs/type3/signatures/
# Local screenshot artifacts from *-screenshots.spec.ts
frontend/editor/screenshots/
frontend/editor/src-tauri/libs/.variant
@@ -4321,7 +4321,8 @@ download = "Download"
downloadAll = "Download all"
downloadVersion = "Download this version"
dropOverlay = "Drop files to upload"
dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organize them into a folder."
dropOverlaySub = "Files land in Local. Organize them into folders any time."
dropOverlaySubFolder = "They'll be added to this folder."
duplicate = "Duplicate"
file = "File"
fileInfo = "File info"
@@ -4334,12 +4335,19 @@ inPath = "in {{path}}"
inWorkspace = "Open"
inWorkspaceAria = "Already in workspace"
loading = "Loading…"
localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to organize it."
localFolderManagedByDisk = "This folder is managed by its directory on disk."
moveAcrossKindsBlocked = "These folders live in different places, so one can't go inside the other."
moveIntoMountCloudSkipped_one = "{{count}} server file stayed in your files. It lives on the server, not on this disk."
moveIntoMountCloudSkipped_other = "{{count}} server files stayed in your files. They live on the server, not on this disk."
moveIntoMountFailed_one = "{{count}} file could not be written into the folder."
moveIntoMountFailed_other = "{{count}} files could not be written into the folder."
moveIntoVirtualCloudSkipped_one = "{{count}} server file was left in place. Server files can't live in browser-only folders."
moveIntoVirtualCloudSkipped_other = "{{count}} server files were left in place. Server files can't live in browser-only folders."
moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)."
moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)."
moveTo = "Move to…"
newFolder = "New folder"
newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on."
newFolderStorageDisabled = "Server folder storage isn't enabled."
newFolderTabUnavailable = "Switch to All or Cloud to create folders."
offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disabled. Check sign-in and storage configuration."
open = "Open"
@@ -4347,6 +4355,7 @@ openVersionInWorkspace = "Open in workspace"
originFilter = "Filter by source"
refresh = "Refresh from server"
remove = "Delete"
removeLocalFolder = "Remove (files stay on disk)"
removeVersion = "Remove this version"
rename = "Rename"
renamed = "Renamed"
@@ -4359,6 +4368,7 @@ selectAll = "Select all"
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
selectedCount = "{{count}} selected"
selectFile = "Select file {{name}}"
serverFolderNeedsConnection = "Sign in to Stirling Cloud or connect a self-hosted server to use server folders."
shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it."
shareManage = "Manage sharing"
showDetails = "Show details"
@@ -4367,7 +4377,6 @@ summary_one = "{{count}} item"
summary_other = "{{count}} items"
tree = "Folders"
upload = "Upload"
uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder."
uploadFromMobile = "Upload from Mobile"
versionActions = "Version actions"
versionCollapse = "Collapse middle versions"
@@ -4433,6 +4442,8 @@ title = "You haven't shared any files yet"
[filesPage.error]
actionFailed = "Could not {{action}}."
actionFailedDetail = "Could not {{action}}: {{message}}"
addFolderFailed = "Could not add the folder."
addFolderFailedDetail = "Could not add the folder: {{message}}"
cloudDeleteFailed_one = "Couldn't delete 1 file from the cloud."
cloudDeleteFailed_other = "Couldn't delete {{count}} files from the cloud."
deleteFolderFailed = "Could not delete folder."
@@ -4445,8 +4456,14 @@ moveFilesFailed = "Could not move files."
moveFilesFailedDetail = "Could not move files: {{message}}"
moveFolderFailed = "Could not move folder."
moveFolderFailedDetail = "Could not move folder: {{message}}"
openDiskFileFailed = "Could not open {{name}}."
openDiskFileFailedDetail = "Could not open {{name}}: {{message}}"
readFolderFailed = "Could not read the folder."
readFolderFailedDetail = "Could not read the folder: {{message}}"
removeFilesFailed = "Could not remove files."
removeFilesFailedDetail = "Could not remove files: {{message}}"
removeFolderFailed = "Could not remove folder."
removeFolderFailedDetail = "Could not remove folder: {{message}}"
uploadFilesFailed = "Could not upload files."
uploadFilesFailedDetail = "Could not upload files: {{message}}"
@@ -4466,12 +4483,21 @@ activeCount = "{{count}} filters active"
clearAll = "Clear filters"
label = "Filters"
[filesPage.folderKind]
local = "Local folder"
virtual = "Browser folder"
[filesPage.folderName]
cancel = "Cancel"
error = "Could not save folder. Try again."
label = "Folder name"
placeholder = "Folder name"
[filesPage.folderOrigin]
diskHint = "A folder mounted from a directory on your disk"
serverHint = "A folder stored on the Stirling server"
virtualHint = "A folder that lives only in this browser"
[filesPage.moveDialog]
cancel = "Cancel"
confirm = "Move here"
@@ -4485,10 +4511,16 @@ newFolderPlaceholder = "Folder name"
newFolderToggle = "Create new folder…"
title = "Move to folder"
[filesPage.newFolderMenu]
addExisting = "Add local folder"
server = "New folder on the server"
serverHint = "Synced to your account, available wherever you sign in."
[filesPage.origin]
all = "All sources"
cloud = "Cloud"
cloudHint = "Stored on the Stirling server"
diskHint = "A file in the mounted folder on your disk"
local = "Local"
localHint = "Only stored in this browser"
shared = "Shared"
@@ -39,6 +39,18 @@
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-read-dir",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-stat",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-mkdir",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-write-file",
"allow": [{ "path": "**" }]
@@ -20,7 +20,13 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import SearchIcon from "@mui/icons-material/Search";
import { FileId } from "@app/types/file";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
import {
FolderId,
FolderRecord,
ROOT_FOLDER_ID,
folderKind,
} from "@app/types/folder";
import type { DiskFileEntry } from "@app/services/localFolderContents";
import { useFolders } from "@app/contexts/FolderContext";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import { StirlingFileStub } from "@app/types/fileContext";
@@ -36,20 +42,64 @@ import { FileOriginBadge } from "@app/components/filesPage/FileOriginBadge";
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
import { findFolderIcon } from "@app/components/filesPage/folderIcons";
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
import {
useLazyThumbnail,
useDiskThumbnail,
} from "@app/hooks/useLazyThumbnail";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem";
/**
* The origin badge a folder wears, mirroring the one its files would: a server folder
* is Cloud, a virtual folder is Local (this browser), a mounted folder is On disk.
*/
function useFolderOriginBadge(folder: FolderRecord): {
origin: "cloud" | "local";
tooltip: string;
} {
const { t } = useTranslation();
switch (folderKind(folder)) {
case "virtual":
return {
origin: "local",
tooltip: t(
"filesPage.folderOrigin.virtualHint",
"A folder that lives only in this browser",
),
};
case "local":
return {
// Same mark as a virtual folder: what matters is that it lives on
// this device, not which corner of it. The tooltip says which.
origin: "local",
tooltip: t(
"filesPage.folderOrigin.diskHint",
"A folder mounted from a directory on your disk",
),
};
default:
return {
origin: "cloud",
tooltip: t(
"filesPage.folderOrigin.serverHint",
"A folder stored on the Stirling server",
),
};
}
}
export type FilesPageViewMode = "grid" | "list";
export interface FilesPageEntry {
kind: "folder" | "file";
kind: "folder" | "file" | "diskFile";
folder?: FolderRecord;
/** Number of files inside this folder (folder entries only). */
folderFileCount?: number;
file?: StirlingFileStub;
/** A file read straight off a mounted directory (kind "diskFile"). */
disk?: DiskFileEntry;
/** Parent breadcrumb path for search results outside the current folder. */
parentPath?: string;
}
@@ -66,6 +116,7 @@ interface FileGridProps {
onOpenFolder: (id: FolderId) => void;
/** "Add to workspace". */
onOpenFile: (file: StirlingFileStub) => void;
onOpenDiskFile?: (entry: DiskFileEntry) => void;
onMoveFiles: (
fileIds: FileId[],
targetFolderId: FolderId | null,
@@ -383,6 +434,7 @@ function GridView(props: FileGridProps) {
onSelectFile,
onOpenFolder,
onOpenFile,
onOpenDiskFile,
onMoveFiles,
onMoveFolder,
onRenameFolder,
@@ -414,6 +466,15 @@ function GridView(props: FileGridProps) {
/>
);
}
if (entry.kind === "diskFile" && entry.disk) {
return (
<DiskFileCard
key={`disk-${entry.disk.path}`}
entry={entry.disk}
onOpen={() => onOpenDiskFile?.(entry.disk!)}
/>
);
}
if (entry.kind === "file" && entry.file) {
return (
<FileCard
@@ -470,6 +531,12 @@ function FolderCard({
}: FolderCardProps) {
const { t } = useTranslation();
const { serverReachable, setError } = useFolders();
// Only a server folder can go offline: the other kinds take their name, look and
// lifetime from elsewhere, so their edit items are hidden rather than disabled.
const kind = folderKind(folder);
const originBadge = useFolderOriginBadge(folder);
const editsDisabled = kind === "server" && !serverReachable;
const editsHidden = kind === "local";
const offlineHint = t(
"filesPage.offlineNoFolderEdits",
"Offline - folder changes are disabled.",
@@ -517,9 +584,7 @@ function FolderCard({
e.dataTransfer.effectAllowed = "move";
}}
{...dropHandlers}
className={`files-page-card is-folder${
isDropTarget ? " is-drop-target" : ""
}`}
className={`files-page-card is-folder${isDropTarget ? " is-drop-target" : ""}`}
onDoubleClick={onOpen}
onContextMenu={(e) => {
e.preventDefault();
@@ -540,6 +605,13 @@ function FolderCard({
fileCount={fileCount}
iconGlyph={findFolderIcon(folder.icon)?.glyph}
/>
<div className="files-page-card-origin">
<FileOriginBadge
origin={originBadge.origin}
tooltip={originBadge.tooltip}
compact
/>
</div>
</div>
<div className="files-page-card-body">
<div className="files-page-card-name" title={folder.name}>
@@ -577,33 +649,51 @@ function FolderCard({
>
{t("filesPage.open", "Open")}
</Menu.Item>
<Menu.Item
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
onClick={onRename}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.rename", "Rename")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>
{t("filesPage.appearance.title", "Appearance")}
</Menu.Label>
<FolderAppearancePicker
folder={folder}
onChange={onChangeAppearance}
disabled={!serverReachable}
/>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={onDelete}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.deleteFolder", "Delete folder")}
</Menu.Item>
{/* Only a mount root can be removed; a subdirectory is the
disk's, and the app never deletes directories. */}
{editsHidden && folder.parentFolderId === null && (
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={onDelete}
>
{t(
"filesPage.removeLocalFolder",
"Remove (files stay on disk)",
)}
</Menu.Item>
)}
{!editsHidden && (
<>
<Menu.Item
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
onClick={onRename}
disabled={editsDisabled}
title={editsDisabled ? offlineHint : undefined}
>
{t("filesPage.rename", "Rename")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>
{t("filesPage.appearance.title", "Appearance")}
</Menu.Label>
<FolderAppearancePicker
folder={folder}
onChange={onChangeAppearance}
disabled={editsDisabled}
/>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={onDelete}
disabled={editsDisabled}
title={editsDisabled ? offlineHint : undefined}
>
{t("filesPage.deleteFolder", "Delete folder")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
</div>
@@ -908,9 +998,7 @@ function FileCard({
onKeyDown={(e) => {
if (e.key === "Enter") onDoubleClick();
}}
className={`files-page-card${isSelected ? " is-selected" : ""}${
isInWorkspace ? " is-in-workspace" : ""
}`}
className={`files-page-card${isSelected ? " is-selected" : ""}${isInWorkspace ? " is-in-workspace" : ""}`}
>
{isInWorkspace && (
<span
@@ -1011,6 +1099,7 @@ function ListView(
onSetSelection,
onOpenFolder,
onOpenFile,
onOpenDiskFile,
onMoveFiles,
onMoveFolder,
onRenameFolder,
@@ -1129,6 +1218,15 @@ function ListView(
/>
);
}
if (entry.kind === "diskFile" && entry.disk) {
return (
<DiskFileRow
key={`disk-${entry.disk.path}`}
entry={entry.disk}
onOpen={() => onOpenDiskFile?.(entry.disk!)}
/>
);
}
if (entry.kind === "file" && entry.file) {
return (
<FileRow
@@ -1183,6 +1281,11 @@ function FolderRow({
}: FolderRowProps) {
const { t } = useTranslation();
const { serverReachable, setError } = useFolders();
// Kinds gate the edit items, as in FolderCard.
const kind = folderKind(folder);
const originBadge = useFolderOriginBadge(folder);
const editsDisabled = kind === "server" && !serverReachable;
const editsHidden = kind === "local";
const offlineHint = t(
"filesPage.offlineNoFolderEdits",
"Offline - folder changes are disabled.",
@@ -1278,8 +1381,19 @@ function FolderRow({
</span>
)}
</span>
<FileOriginBadge
origin={originBadge.origin}
tooltip={originBadge.tooltip}
compact
/>
</span>
<span role="gridcell">
{kind === "virtual"
? t("filesPage.folderKind.virtual", "Browser folder")
: kind === "local"
? t("filesPage.folderKind.local", "Local folder")
: t("filesPage.folder", "Folder")}
</span>
<span role="gridcell">{t("filesPage.folder", "Folder")}</span>
<span role="gridcell">
{fileCount === 0
? "-"
@@ -1308,33 +1422,51 @@ function FolderRow({
>
{t("filesPage.open", "Open")}
</Menu.Item>
<Menu.Item
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
onClick={onRename}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.rename", "Rename")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>
{t("filesPage.appearance.title", "Appearance")}
</Menu.Label>
<FolderAppearancePicker
folder={folder}
onChange={onChangeAppearance}
disabled={!serverReachable}
/>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={onDelete}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.deleteFolder", "Delete folder")}
</Menu.Item>
{/* Only a mount root can be removed; a subdirectory is the
disk's, and the app never deletes directories. */}
{editsHidden && folder.parentFolderId === null && (
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={onDelete}
>
{t(
"filesPage.removeLocalFolder",
"Remove (files stay on disk)",
)}
</Menu.Item>
)}
{!editsHidden && (
<>
<Menu.Item
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
onClick={onRename}
disabled={editsDisabled}
title={editsDisabled ? offlineHint : undefined}
>
{t("filesPage.rename", "Rename")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>
{t("filesPage.appearance.title", "Appearance")}
</Menu.Label>
<FolderAppearancePicker
folder={folder}
onChange={onChangeAppearance}
disabled={editsDisabled}
/>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={onDelete}
disabled={editsDisabled}
title={editsDisabled ? offlineHint : undefined}
>
{t("filesPage.deleteFolder", "Delete folder")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
</span>
@@ -1402,9 +1534,7 @@ function FileRow({
onKeyDown={(e) => {
if (e.key === "Enter") onOpen();
}}
className={`files-page-list-row${isSelected ? " is-selected" : ""}${
isInWorkspace ? " is-in-workspace" : ""
}`}
className={`files-page-list-row${isSelected ? " is-selected" : ""}${isInWorkspace ? " is-in-workspace" : ""}`}
>
{/* Each direct child is a gridcell: a role="row" may only own cells, so the
checkbox and the actions menu have to sit inside one.
@@ -1516,3 +1646,194 @@ function FileRow({
// Re-export root constant for caller convenience
export { ROOT_FOLDER_ID };
/**
* No stub behind it, so no selection, move, rename or delete: the disk owns the
* file and the only affordance is adding it to the workspace.
*/
function DiskFileCard({
entry,
onOpen,
}: {
entry: DiskFileEntry;
onOpen: () => void;
}) {
const { t } = useTranslation();
const thumbnail = useDiskThumbnail(entry);
const extension = entry.name.includes(".")
? entry.name.split(".").pop()!.toUpperCase()
: "";
const isPdf = extension === "PDF";
return (
<div
className="files-page-card"
role="listitem"
tabIndex={0}
onDoubleClick={onOpen}
onKeyDown={(e) => {
if (e.key === "Enter") onOpen();
}}
title={entry.path}
>
<div className="files-page-card-thumb">
{thumbnail ? (
<img src={thumbnail} alt="" draggable={false} />
) : (
<div className="files-page-card-thumb-fallback">
{isPdf ? (
<PictureAsPdfIcon style={{ fontSize: "2rem" }} />
) : (
<InsertDriveFileIcon style={{ fontSize: "2rem" }} />
)}
<span>{extension || "FILE"}</span>
</div>
)}
<div className="files-page-card-origin">
<FileOriginBadge
origin="local"
tooltip={t(
"filesPage.origin.diskHint",
"A file in the mounted folder on your disk",
)}
compact
/>
</div>
</div>
<div className="files-page-card-body">
<div className="files-page-card-name" title={entry.name}>
{entry.name}
</div>
<div className="files-page-card-meta">
<span>{formatFileSize(entry.sizeBytes)}</span>
<span>·</span>
<span>{getFileDate({ lastModified: entry.lastModified })}</span>
</div>
</div>
<div className="files-page-card-actions">
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
{t("filesPage.addToWorkspace", "Add to workspace")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
</div>
);
}
/** List-view sibling of {@link DiskFileCard}; same single affordance. */
function DiskFileRow({
entry,
onOpen,
}: {
entry: DiskFileEntry;
onOpen: () => void;
}) {
const { t } = useTranslation();
const thumbnail = useDiskThumbnail(entry);
const ext = entry.name.includes(".")
? entry.name.split(".").pop()!.toUpperCase()
: "";
return (
<div
role="row"
tabIndex={0}
className="files-page-list-row"
onDoubleClick={onOpen}
onKeyDown={(e) => {
if (e.key === "Enter") onOpen();
}}
title={entry.path}
>
<span aria-hidden="true" />
<span
role="gridcell"
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
minWidth: 0,
}}
>
{thumbnail ? (
<img
src={thumbnail}
alt=""
draggable={false}
style={{
width: "1.5rem",
height: "1.5rem",
objectFit: "cover",
borderRadius: "0.25rem",
}}
/>
) : ext === "PDF" ? (
<PictureAsPdfIcon fontSize="small" />
) : (
<InsertDriveFileIcon fontSize="small" />
)}
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={entry.name}
>
{entry.name}
</span>
<FileOriginBadge
origin="local"
tooltip={t(
"filesPage.origin.diskHint",
"A file in the mounted folder on your disk",
)}
compact
/>
</span>
<span role="gridcell">{ext || t("filesPage.file", "File")}</span>
<span role="gridcell">{formatFileSize(entry.sizeBytes)}</span>
<span role="gridcell">
{getFileDate({ lastModified: entry.lastModified })}
</span>
<span role="gridcell">
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="tertiary"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={onOpen}
>
{t("filesPage.addToWorkspace", "Add to workspace")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</span>
</div>
);
}
@@ -10,8 +10,10 @@ import { useLocation, useNavigate } from "react-router-dom";
import {
Drawer,
Group,
Menu,
MultiSelect,
Select,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
@@ -32,6 +34,9 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import DriveFolderUploadIcon from "@mui/icons-material/DriveFolderUpload";
import CloudIcon from "@mui/icons-material/Cloud";
import RefreshIcon from "@mui/icons-material/Refresh";
import { FilesToolbarBulkMenu } from "@app/components/filesPage/FilesToolbarBulkMenu";
import { FilesToolbarCount } from "@app/components/filesPage/FilesToolbarCount";
@@ -45,6 +50,7 @@ import { useFolders } from "@app/contexts/FolderContext";
import { useFileActions } from "@app/contexts/file/fileHooks";
import { useAllFiles } from "@app/contexts/FileContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useServerFolderBlock } from "@app/hooks/useServerFolderBlock";
import {
useNavigationActions,
useNavigationGuard,
@@ -60,7 +66,7 @@ import { getFileOrigin } from "@app/components/filesPage/fileOrigin";
import { FileId } from "@app/types/file";
import { StirlingFileStub } from "@app/types/fileContext";
import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder";
import { FolderId, ROOT_FOLDER_ID, folderKind } from "@app/types/folder";
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
@@ -69,6 +75,20 @@ import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { canPickDirectory } from "@app/services/directoryPicker";
import {
diskFolderId,
isDiskFolderId,
pickFolderColor,
} from "@app/types/folder";
import { useNewFolderFlow } from "@app/hooks/useNewFolderFlow";
import { writeIntoMount } from "@app/services/mountWrites";
import {
canListDirectory,
listDirectory,
readDiskFile,
type DiskFileEntry,
} from "@app/services/localFolderContents";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog";
@@ -201,6 +221,7 @@ export default function FileManagerView() {
);
const setCurrentFolderId = folders.setCurrentFolderId;
const resolveDiskFolder = folders.resolveDiskFolder;
const foldersById = folders.foldersById;
const currentFolderId = folders.currentFolderId;
@@ -212,10 +233,13 @@ export default function FileManagerView() {
setCurrentFolderId(ROOT_FOLDER_ID);
} else if (foldersById.has(param as FolderId)) {
setCurrentFolderId(param as FolderId);
} else if (isDiskFolderId(param) && resolveDiskFolder(param as FolderId)) {
// A mount subdirectory deep link: rebuilt from the id, mapped next render.
setCurrentFolderId(param as FolderId);
} else {
setCurrentFolderId(ROOT_FOLDER_ID);
}
}, [location.pathname, foldersById, setCurrentFolderId]);
}, [location.pathname, foldersById, setCurrentFolderId, resolveDiskFolder]);
// Bounce off any share-related tab when sharing isn't enabled.
useEffect(() => {
@@ -275,6 +299,8 @@ export default function FileManagerView() {
}
const lc = search.toLowerCase();
const matched = folders.folders.filter((f) => {
// The Cloud tab is the server's view: browser folders and mounts aren't on it.
if (currentTab === "cloud" && folderKind(f) !== "server") return false;
if (search) {
// Subtree-wide name match; exclude the current folder itself.
return (
@@ -296,10 +322,11 @@ export default function FileManagerView() {
// Tab overrides folder navigation for Local/Recent/Shared.
switch (currentTab) {
case "local":
// Local = files with no server copy. folderId is forced null on this
// path (cf. file.ts comment), but we check remoteStorageId too so
// stale local-folder rows from a pre-pivot DB don't slip through.
return allFiles.filter((f) => f.remoteStorageId == null);
// Both halves: a local file inside a browser folder belongs to that folder,
// not here as well.
return allFiles.filter(
(f) => f.remoteStorageId == null && (f.folderId ?? null) === null,
);
case "cloud":
// Cloud bucket; search widens to subtree, else direct-folder match.
return allFiles.filter((f) => {
@@ -426,12 +453,141 @@ export default function FileManagerView() {
[foldersById],
);
const currentFolder = currentFolderId
? folders.foldersById.get(currentFolderId)
: undefined;
const currentLocalDirectory =
currentFolder && folderKind(currentFolder) === "local"
? currentFolder.directory
: undefined;
const { setError: setFolderError, registerDiskSubfolders } = folders;
const [diskEntries, setDiskEntries] = useState<DiskFileEntry[]>([]);
const [diskLoading, setDiskLoading] = useState(false);
// Bumped when this view writes into the directory, so the listing re-reads.
const [diskRefreshTick, setDiskRefreshTick] = useState(0);
useEffect(() => {
if (!currentLocalDirectory || !canListDirectory) {
setDiskEntries([]);
// Leaving a mount mid-listing cancels the in-flight reset, so clear the
// flag here or the skeleton covers every folder for the rest of the session.
setDiskLoading(false);
return;
}
let cancelled = false;
setDiskLoading(true);
listDirectory(currentLocalDirectory)
.then((listed) => {
if (cancelled) return;
setDiskEntries(listed?.files ?? []);
if (currentFolderId !== null) {
registerDiskSubfolders(
currentFolderId,
(listed?.directories ?? []).map((dir) => ({
id: diskFolderId(dir.path),
kind: "local" as const,
name: dir.name,
parentFolderId: currentFolderId,
directory: dir.path,
color: pickFolderColor(dir.name),
createdAt: 0,
updatedAt: 0,
})),
);
}
})
.catch((err) => {
console.warn("[FileManagerView] disk listing failed", err);
if (!cancelled) {
setDiskEntries([]);
setFolderError(
err instanceof Error
? t("filesPage.error.readFolderFailedDetail", {
message: err.message,
defaultValue: `Could not read the folder: ${err.message}`,
})
: t(
"filesPage.error.readFolderFailed",
"Could not read the folder.",
),
);
}
})
.finally(() => {
if (!cancelled) setDiskLoading(false);
});
return () => {
cancelled = true;
};
// The stable setter, not the context: its identity changes on every folder
// mutation, including the setError above, so a failing listing would re-trigger.
}, [
currentLocalDirectory,
currentFolderId,
registerDiskSubfolders,
setFolderError,
diskRefreshTick,
t,
]);
const openDiskFile = useCallback(
async (entry: DiskFileEntry) => {
try {
const file = await readDiskFile(entry);
if (!file) return;
clearFilesPageReturnRoute();
await addFiles([file], { selectFiles: true });
navActions.setWorkbench("viewer");
navigate("/");
} catch (err) {
folders.setError(
err instanceof Error
? t("filesPage.error.openDiskFileFailedDetail", {
name: entry.name,
message: err.message,
defaultValue: `Could not open ${entry.name}: ${err.message}`,
})
: t("filesPage.error.openDiskFileFailed", {
name: entry.name,
defaultValue: `Could not open ${entry.name}.`,
}),
);
}
},
[addFiles, navActions, navigate, folders, t],
);
const entries = useMemo<FilesPageEntry[]>(() => {
// When searching, items may come from anywhere in the subtree, so we
// expose a "parentPath" subtitle whenever the item's parent differs from
// currentFolderId. When no search is active, every item is in the
// current folder by definition and the subtitle is suppressed.
const inSearch = search.length > 0;
// Inside a mount the listing is the directory; storage rows don't apply.
if (currentLocalDirectory) {
const needle = search.toLowerCase();
const compare: Record<
string,
(a: DiskFileEntry, b: DiskFileEntry) => number
> = {
"name-asc": (a, b) => a.name.localeCompare(b.name),
"name-desc": (a, b) => b.name.localeCompare(a.name),
"size-asc": (a, b) => a.sizeBytes - b.sizeBytes,
"size-desc": (a, b) => b.sizeBytes - a.sizeBytes,
"modified-asc": (a, b) => a.lastModified - b.lastModified,
"modified-desc": (a, b) => b.lastModified - a.lastModified,
};
return [
...visibleFolders.map<FilesPageEntry>((folder) => ({
kind: "folder",
folder,
folderFileCount: 0,
})),
...diskEntries
.filter((disk) => !needle || disk.name.toLowerCase().includes(needle))
.sort(compare[filesPage.sortMode] ?? compare["modified-desc"]!)
.map<FilesPageEntry>((disk) => ({ kind: "diskFile", disk })),
];
}
return [
...visibleFolders.map<FilesPageEntry>((folder) => ({
kind: "folder",
@@ -457,6 +613,9 @@ export default function FileManagerView() {
filesPage.fileCountsByFolder,
search,
currentFolderId,
currentLocalDirectory,
diskEntries,
filesPage.sortMode,
pathForFolderId,
]);
@@ -531,28 +690,46 @@ export default function FileManagerView() {
// state - otherwise the file pops up the next time the user navigates
// to /viewer or /tools, which reads as "auto-opened" and surprised
// people every time. The grid will repaint via refresh() below.
// Files uploaded while standing in a folder belong in that folder.
const target =
currentTab === "all" || currentTab === "cloud" ? currentFolderId : null;
const targetFolder = target ? folders.foldersById.get(target) : undefined;
if (targetFolder && folderKind(targetFolder) === "local") {
const { failedCount } = await writeIntoMount(
targetFolder.directory,
files.map((file) => ({ name: file.name, bytes: async () => file })),
);
if (failedCount > 0) {
folders.setError(
t("filesPage.moveIntoMountFailed", {
count: failedCount,
defaultValue:
"{{count}} file(s) could not be written into the folder.",
}),
);
}
setDiskRefreshTick((tick) => tick + 1);
return;
}
// Everywhere else membership is set with the stub rather than by a move that
// could fail after. For a server folder it stays local until the save lands.
const added = await addFiles(files, {
selectFiles: false,
skipWorkspaceDispatch: true,
...(target ? { folderId: target as string } : {}),
});
const fileIds = added.map((f) => f.fileId);
const target = currentFolderId;
// Uploaded files land in Local (folderId stays null).
if (
target !== null &&
fileIds.length > 0 &&
(currentTab === "all" || currentTab === "cloud")
targetFolder &&
folderKind(targetFolder) === "server"
) {
folders.setError(
t(
"filesPage.uploadedToLocal",
"Uploaded files start in Local. Use 'Save to cloud' to put them in a folder.",
),
);
await moveFilesTo(fileIds, target);
}
await refresh();
},
[addFiles, currentFolderId, currentTab, folders, refresh, t],
[addFiles, currentFolderId, currentTab, folders, moveFilesTo, refresh, t],
);
const onFileInputChange = useCallback(
@@ -913,20 +1090,18 @@ export default function FileManagerView() {
[selectedFiles, fileMap],
);
// Per-destination availability for the New-folder menu; the reason renders as the
// disabled item's caption.
const serverFolderDisabledReason = useServerFolderBlock() ?? undefined;
const { addLocalFolder, createFolderHere, createFolderHereBlockedReason } =
useNewFolderFlow();
// null = New folder actionable; string = disabled tooltip reason.
const newFolderDisabledReason: string | null = useMemo(() => {
// Guests can't use cloud folders at all - say so before any tab/storage
// hint, since switching tabs wouldn't help them.
if (signInRequiredReason) {
return signInRequiredReason;
}
if (currentTab === "local") {
return t(
"filesPage.localFoldersUnavailable",
"Folders are cloud-only - save a file to the cloud to organise it.",
);
}
// Only All/Cloud render folders, so creating one elsewhere would look inert.
if (
currentTab === "local" ||
currentTab === "recent" ||
currentTab === "shared" ||
currentTab === "sharedByMe"
@@ -936,14 +1111,32 @@ export default function FileManagerView() {
"Switch to All or Cloud to create folders.",
);
}
if (!folders.serverReachable) {
return t(
"filesPage.newFolderStorageDisabled",
"Server folder storage isn't enabled. Ask your admin to turn it on.",
);
// A subfolder inherits kind server, so the blockers gate the button rather
// than letting the dialog open and fail at submit.
if (
currentFolder &&
folderKind(currentFolder) === "server" &&
serverFolderDisabledReason
) {
return serverFolderDisabledReason;
}
// The web root creates on the server or not at all.
if (
folders.currentFolderId === null &&
!canPickDirectory &&
serverFolderDisabledReason
) {
return serverFolderDisabledReason;
}
return null;
}, [signInRequiredReason, currentTab, folders.serverReachable, t]);
}, [
currentTab,
currentLocalDirectory,
currentFolder,
folders.currentFolderId,
serverFolderDisabledReason,
t,
]);
return (
<div className="files-page" ref={dropZoneRef}>
@@ -977,6 +1170,10 @@ export default function FileManagerView() {
const handleRefresh = async () => {
setRefreshing(true);
try {
// In a mount, refresh means the directory: the listing only re-reads when told.
if (currentLocalDirectory) {
setDiskRefreshTick((tick) => tick + 1);
}
// pullFromServer bumps the folder revision, which the
// FolderProvider's effect reacts to by re-running refresh() -
// no need to await folders.refresh() manually.
@@ -1045,15 +1242,69 @@ export default function FileManagerView() {
</Button>
</span>
</Tooltip>
) : (
) : folders.currentFolderId !== null || !canPickDirectory ? (
// Nothing to choose: a subfolder inherits its parent's kind, and on
// the web everything lives on the server. Straight to the dialog.
<Button
variant="secondary"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={() => openNewFolderDialog()}
onClick={() =>
folders.currentFolderId !== null
? openNewFolderDialog()
: openNewFolderDialog(null, "server")
}
>
{t("filesPage.newFolder", "New folder")}
</Button>
) : (
// Desktop root: two peer destinations, so the button is the menu.
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<Button
variant="secondary"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
rightSection={<ArrowDropDownIcon fontSize="small" />}
>
{t("filesPage.newFolder", "New folder")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
<DriveFolderUploadIcon
fontSize="small"
style={{ marginRight: "0.3rem" }}
/>
}
onClick={() => void addLocalFolder()}
>
{t(
"filesPage.newFolderMenu.addExisting",
"Add local folder",
)}
</Menu.Item>
<Menu.Item
className="files-page-new-folder-option"
leftSection={<CloudIcon fontSize="small" />}
disabled={Boolean(serverFolderDisabledReason)}
onClick={() => openNewFolderDialog(null, "server")}
>
{t(
"filesPage.newFolderMenu.server",
"New folder on the server",
)}
<Text size="xs" c="dimmed">
{serverFolderDisabledReason ??
t(
"filesPage.newFolderMenu.serverHint",
"Synced to your account, available wherever you sign in.",
)}
</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
<Button
size="sm"
@@ -1647,7 +1898,7 @@ export default function FileManagerView() {
>
<FileGrid
entries={entries}
loading={loading}
loading={loading || diskLoading}
currentTab={currentTab}
searchActive={search.trim().length > 0}
serverReachable={folders.serverReachable}
@@ -1659,6 +1910,7 @@ export default function FileManagerView() {
onSelectFile={handleSelectFile}
onSetSelection={setSelectedFileIds}
onOpenFolder={handleOpenFolder}
onOpenDiskFile={(entry) => void openDiskFile(entry)}
onOpenFile={handleOpenFile}
onMoveFiles={moveFilesTo}
onMoveFolder={moveFolderTo}
@@ -1692,8 +1944,12 @@ export default function FileManagerView() {
// (disabled tooltips, native file picker, dialog) is
// identical regardless of where the user clicks from.
onEmptyUpload={() => fileInputRef.current?.click()}
onEmptyCreateFolder={() => openNewFolderDialog()}
newFolderDisabledReason={newFolderDisabledReason}
onEmptyCreateFolder={createFolderHere}
// A single-click shortcut, so it also blocks where it has nothing safe
// to do, unlike the header button whose menu still offers the choices.
newFolderDisabledReason={
newFolderDisabledReason ?? createFolderHereBlockedReason
}
/>
{isDraggingExternal && (
<div className="files-page-drop-overlay" aria-live="polite">
@@ -1704,16 +1960,21 @@ export default function FileManagerView() {
{t("filesPage.dropOverlay", "Drop files to upload")}
</span>
<span className="files-page-drop-overlay-sub">
{/* Behavior contract: per handleNativeUpload above, all
newly-uploaded files start in Local (folderId stays
null) regardless of the current folder view. Saying
"will land in {folder}" was a lie; tell the truth
so the user reaches for Save-to-cloud / Move-to when
they actually want a folder placement. */}
{t(
"filesPage.dropOverlaySub",
"Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder.",
)}
{/* Behavior contract: per handleNativeUpload above, files
dropped inside a folder on the All/Cloud views are
placed into it — a mount takes them onto the disk
itself. Other tabs land drops in Local, so the copy
must match. */}
{(currentTab === "all" || currentTab === "cloud") &&
currentFolderId !== null
? t(
"filesPage.dropOverlaySubFolder",
"They'll be added to this folder.",
)
: t(
"filesPage.dropOverlaySub",
"Files land in Local. Organise them into folders any time.",
)}
</span>
</div>
)}
@@ -1774,7 +2035,14 @@ export default function FileManagerView() {
<MoveToFolderDialog
opened={moveDialog.open}
onClose={closeMoveDialog}
folders={folders.folders}
// Files can go anywhere, but a folder moves only within its own kind and
// never into a mount - a directory's subfolders are the filesystem's.
folders={folders.folders.filter((candidate) => {
if (!moveDialog.folderId) return true;
if (folderKind(candidate) === "local") return false;
const moving = folders.foldersById.get(moveDialog.folderId);
return moving ? folderKind(candidate) === folderKind(moving) : true;
})}
initialFolderId={moveDialog.initial}
disabledFolderId={moveDialog.folderId}
onConfirm={async (target) => {
@@ -11,6 +11,7 @@ interface FileOriginBadgeProps {
origin: FileOrigin;
/** Compact (icon-only) vs full (icon + text). */
compact?: boolean;
tooltip?: string;
}
const styles = {
@@ -44,6 +45,7 @@ const styles = {
export function FileOriginBadge({
origin,
compact = false,
tooltip,
}: FileOriginBadgeProps) {
const { t } = useTranslation();
@@ -88,7 +90,7 @@ export function FileOriginBadge({
);
return (
<Tooltip label={config.tooltip} withinPortal>
<Tooltip label={tooltip ?? config.tooltip} withinPortal>
{badge}
</Tooltip>
);
@@ -582,8 +582,13 @@
position: absolute;
bottom: 0.4rem;
left: 0.4rem;
/* The overlay itself stays transparent to the card's clicks and drags, but
the badge inside must catch hover or its tooltip can never open. */
pointer-events: none;
}
.files-page-card-origin > * {
pointer-events: auto;
}
/* "Open" badge - file is currently loaded in the active workspace.
Solid pill with white text so it reads against any thumbnail
@@ -1494,3 +1499,14 @@
transform: none;
}
}
/* A disabled destination still has to be read - its caption carries the reason - and
Mantine's disabled colour drops below comfortable contrast in dark mode. Selector
stands on the item's own class because the dropdown renders in a portal. */
.files-page-new-folder-option[data-disabled] {
color: var(--c-text-muted) !important;
opacity: 1;
}
.files-page-new-folder-option[data-disabled] .mantine-Text-root {
color: var(--c-text-subtle) !important;
}
@@ -16,6 +16,7 @@ import { useFolders } from "@app/contexts/FolderContext";
import { FileId } from "@app/types/file";
import {
FolderId,
folderKind,
FolderRecord,
FolderTreeNode,
ROOT_FOLDER_ID,
@@ -260,6 +261,12 @@ function TreeNodeRow({
}: TreeNodeRowProps) {
const { t } = useTranslation();
const { serverReachable, setError } = useFolders();
// Server folders need the server; a virtual folder is browser-owned and a
// local one is managed by its directory, so its edit items disable with a
// kind-specific hint instead of a wrong "offline" excuse.
const kind = folderKind(node.folder);
const editsDisabled =
kind === "local" || (kind === "server" && !serverReachable);
const { currentTab } = useFilesPage();
const offlineHint = t(
"filesPage.offlineNoFolderEdits",
@@ -433,8 +440,17 @@ function TreeNodeRow({
e.stopPropagation();
onRenameFolder(node.folder);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
disabled={editsDisabled}
title={
kind === "local"
? t(
"filesPage.localFolderManagedByDisk",
"This folder is managed by its directory on disk.",
)
: editsDisabled
? offlineHint
: undefined
}
>
{t("filesPage.treeMenu.rename", "Rename")}
</Menu.Item>
@@ -444,24 +460,48 @@ function TreeNodeRow({
e.stopPropagation();
onRequestNewFolder(node.folder.id);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
disabled={editsDisabled}
title={
kind === "local"
? t(
"filesPage.localFolderManagedByDisk",
"This folder is managed by its directory on disk.",
)
: editsDisabled
? offlineHint
: undefined
}
>
{t("filesPage.treeMenu.newSubfolder", "New subfolder")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteOutlineIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(node.folder);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.treeMenu.delete", "Delete folder")}
</Menu.Item>
{/* Every kind can be removed except a mount's subdirectory, which
is the disk's — the app never deletes directories. A mount
root's removal deletes the record and nothing on disk, so only
the server kind's reachability gate applies. */}
{(kind !== "local" || node.folder.parentFolderId === null) && (
<Menu.Item
color="red"
leftSection={<DeleteOutlineIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(node.folder);
}}
disabled={kind === "server" && !serverReachable}
title={
kind === "server" && !serverReachable
? offlineHint
: undefined
}
>
{kind === "local"
? t(
"filesPage.removeLocalFolder",
"Remove (files stay on disk)",
)
: t("filesPage.treeMenu.delete", "Delete folder")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</div>
@@ -267,6 +267,8 @@ function FileContextInner({
skipWorkspaceDispatch?: boolean;
skipUploadTracking?: boolean;
derivedFromTool?: boolean;
/** Folder every added file is born into (see AddFileOptions). */
folderId?: string;
},
): Promise<StirlingFile[]> => {
const stirlingFiles = await addFiles(
@@ -13,8 +13,15 @@ import { useTranslation } from "react-i18next";
import { FileId } from "@app/types/file";
import { StirlingFileStub } from "@app/types/fileContext";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
import {
FolderId,
FolderKind,
FolderRecord,
ROOT_FOLDER_ID,
folderKind,
} from "@app/types/folder";
import { fileStorage } from "@app/services/fileStorage";
import { writeIntoMount } from "@app/services/mountWrites";
import { folderSyncService } from "@app/services/folderSyncService";
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
import { reconcileServerFiles } from "@app/services/fileSyncService";
@@ -59,6 +66,8 @@ export type FilesPageTab =
export interface FolderNameDialogState {
mode: "new" | "rename" | null;
parentId?: FolderId | null;
/** For a root-level create: the kind the caller chose (menu, not dialog). */
kind?: FolderKind;
folder?: FolderRecord;
}
@@ -102,7 +111,7 @@ interface FilesPageContextValue {
// Dialog state
folderNameDialog: FolderNameDialogState;
openNewFolderDialog: (parentId?: FolderId | null) => void;
openNewFolderDialog: (parentId?: FolderId | null, kind?: FolderKind) => void;
openRenameFolderDialog: (folder: FolderRecord) => void;
closeFolderNameDialog: () => void;
submitFolderName: (name: string) => Promise<void>;
@@ -243,8 +252,11 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
useState<FolderNameDialogState>({ mode: null });
const openNewFolderDialog = useCallback(
(parentId: FolderId | null = folders.currentFolderId) => {
setFolderNameDialog({ mode: "new", parentId });
(
parentId: FolderId | null = folders.currentFolderId,
kind?: FolderKind,
) => {
setFolderNameDialog({ mode: "new", parentId, kind });
},
[folders.currentFolderId],
);
@@ -260,9 +272,11 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const submitFolderName = useCallback(
async (name: string) => {
if (folderNameDialog.mode === "new") {
// Chosen before the dialog opened, and only used at the root.
await folders.createFolder(
name,
folderNameDialog.parentId ?? folders.currentFolderId,
folderNameDialog.kind,
);
} else if (
folderNameDialog.mode === "rename" &&
@@ -297,13 +311,89 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const moveFilesTo = useCallback(
async (fileIds: FileId[], folderId: FolderId | null) => {
if (fileIds.length === 0) return;
const stubs = fileIds
.map((id) => fileMap.get(id))
.filter((s): s is StirlingFileStub => Boolean(s));
// fileMap is a render-time snapshot, so a file created moments ago is not in
// it yet. Storage is the truth, and falling back keeps it in the move.
const fetched = await Promise.all(
fileIds.map(
(id) => fileMap.get(id) ?? fileStorage.getStirlingFileStub(id),
),
);
const stubs = fetched.filter((s): s is StirlingFileStub => Boolean(s));
const localOnly = stubs.filter((s) => s.remoteStorageId == null);
// Cloud list is mutated below with newly-promoted local files.
const cloudFiles = stubs.filter((s) => s.remoteStorageId != null);
const targetFolder =
folderId === null ? null : folders.foldersById.get(folderId);
const targetKind = targetFolder ? folderKind(targetFolder) : null;
if (targetKind === "local") {
// In a mount means on the disk: write each file into the directory, then retire
// the app-side copy once the bytes verifiably landed.
const { written, failedCount } = await writeIntoMount(
targetFolder?.directory,
localOnly.map((stub) => ({
name: stub.name,
bytes: () => fileStorage.getStirlingFile(stub.id),
})),
);
const movedIds = localOnly
.filter((_, i) => written[i])
.map((stub) => stub.id);
if (movedIds.length > 0) {
// Superseded versions go too, or their bytes sit in storage unseen.
const orphans = await fileStorage.orphanedAncestorIds(movedIds);
await fileActions.removeFiles([...movedIds, ...orphans], true);
}
// One error slot, two possible failures: report both.
const notices: string[] = [];
if (failedCount > 0) {
notices.push(
t("filesPage.moveIntoMountFailed", {
count: failedCount,
defaultValue:
"{{count}} file(s) could not be written into the folder.",
}),
);
}
if (cloudFiles.length > 0) {
notices.push(
t("filesPage.moveIntoMountCloudSkipped", {
count: cloudFiles.length,
defaultValue:
"{{count}} server file(s) stayed in your files. They live on the server, not on this disk.",
}),
);
}
if (notices.length > 0) {
folders.setError(notices.join(" "));
}
await refresh();
return;
}
if (targetKind === "virtual") {
// A browser-owned folder cannot hold server files: the next sync would snap
// them back, so they are left where they are and reported.
if (cloudFiles.length > 0) {
folders.setError(
t(
"filesPage.moveIntoVirtualCloudSkipped",
"{{count}} server file(s) were left in place. Server files can't live in browser-only folders.",
{ count: cloudFiles.length },
),
);
}
if (localOnly.length > 0) {
await indexedDB.moveFilesToFolder(
localOnly.map((s) => s.id),
folderId,
);
}
await refresh();
return;
}
if (folderId !== null && localOnly.length > 0) {
// Per-file uploadHistoryChain so each gets its own remoteStorageId.
try {
@@ -379,7 +469,17 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
}
}
// Local files moving to ROOT need no cloud write.
// Local files moving to the root DO need a write when they are leaving a folder —
// their membership is a browser-side folderId that nothing above has touched (the
// upload branch only runs for a non-null target).
if (folderId === null && localOnly.length > 0) {
const leaving = localOnly
.filter((s) => (s.folderId ?? null) !== null)
.map((s) => s.id);
if (leaving.length > 0) {
await indexedDB.moveFilesToFolder(leaving, null);
}
}
await refresh();
},
[indexedDB, refresh, fileMap, folders, t, fileActions],
@@ -397,6 +497,22 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
);
return;
}
// A subtree is one kind throughout (each kind has its own system of
// record), so a cross-kind drop is refused here as a message rather
// than surfacing as a thrown error from the context.
if (newParentId !== null) {
const source = folders.foldersById.get(folderId);
const target = folders.foldersById.get(newParentId);
if (source && target && folderKind(source) !== folderKind(target)) {
folders.setError(
t(
"filesPage.moveAcrossKindsBlocked",
"These folders live in different places, so one can't go inside the other.",
),
);
return;
}
}
await folders.moveFolder(folderId, newParentId);
},
[folders, t],
@@ -554,10 +670,29 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const promptDeleteFolder = useCallback(
(folder: FolderRecord) => {
if (folderKind(folder) === "local") {
// Removing a mount destroys nothing — the record goes, the directory and every
// file in it stay — so there is nothing to warn about and the delete dialog's
// "what about the files?" question would be a scary lie.
void folders.deleteFolder(folder.id).catch((err) => {
folders.setError(
err instanceof Error
? t("filesPage.error.removeFolderFailedDetail", {
message: err.message,
defaultValue: `Could not remove folder: ${err.message}`,
})
: t(
"filesPage.error.removeFolderFailed",
"Could not remove folder.",
),
);
});
return;
}
const fileCount = filesInSubtree(folder.id).length;
setDeleteFolderDialog({ folder, fileCount });
},
[filesInSubtree],
[filesInSubtree, folders, t],
);
const deleteFolder = useCallback(
@@ -93,6 +93,26 @@ vi.mock("@app/services/folderStorage", () => ({
},
}));
// The virtual store is exercised by its own suite (virtualFolderStorage.test);
// here it only needs to exist and be empty so the merged load resolves.
vi.mock("@app/services/virtualFolderStorage", () => ({
virtualFolderStorage: {
getAllFolders: vi.fn(() => Promise.resolve([])),
createFolder: vi.fn(),
updateFolder: vi.fn(),
moveFolder: vi.fn(),
deleteFolder: vi.fn(() => Promise.resolve([])),
},
}));
vi.mock("@app/services/localFolderStorage", () => ({
localFolderStorage: {
getAllFolders: vi.fn(() => Promise.resolve([])),
mountDirectory: vi.fn(),
removeFolder: vi.fn(() => Promise.resolve()),
},
}));
vi.mock("@app/contexts/IndexedDBContext", () => ({
useIndexedDB: () => ({
clearFolderForFiles: vi.fn().mockResolvedValue(undefined),
@@ -26,16 +26,25 @@ import React, {
} from "react";
import { folderStorage } from "@app/services/folderStorage";
import { virtualFolderStorage } from "@app/services/virtualFolderStorage";
import { localFolderStorage } from "@app/services/localFolderStorage";
import { folderSyncService } from "@app/services/folderSyncService";
import {
FolderBreadcrumbEntry,
FolderId,
FolderKind,
FolderRecord,
FolderTreeNode,
ROOT_FOLDER_ID,
createFolderId,
diskFolderId,
diskFolderPath,
folderKind,
isDiskFolderId,
pickFolderColor,
} from "@app/types/folder";
import { directoryKey } from "@app/services/localFolderStorage";
import { makeDiskDirectory } from "@app/services/localFolderContents";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
@@ -88,9 +97,11 @@ interface FolderContextValue {
ok: boolean;
reason?: "endpoint-missing" | "network" | "server" | "client";
}>;
/** Create a folder. A child takes its parent's kind; only a root create chooses. */
createFolder: (
name: string,
parentFolderId?: FolderId | null,
kind?: FolderKind,
) => Promise<FolderRecord>;
renameFolder: (id: FolderId, name: string) => Promise<FolderRecord | null>;
moveFolder: (
@@ -102,6 +113,14 @@ interface FolderContextValue {
appearance: { color?: string; icon?: string | null },
) => Promise<FolderRecord | null>;
deleteFolder: (id: FolderId) => Promise<FolderId[]>;
/** Idempotent per directory: mounting one already mounted returns its record. */
mountLocalFolder: (directory: string, name: string) => Promise<FolderRecord>;
registerDiskSubfolders: (parentId: FolderId, records: FolderRecord[]) => void;
/**
* Rebuild the records behind a disk-subfolder id, for a link arriving before any
* listing ran. True when it sits under a known mount and is now registered.
*/
resolveDiskFolder: (id: FolderId) => boolean;
getChildFolderIds: (parentId: FolderId | null) => FolderId[];
isDescendant: (candidateId: FolderId, ancestorId: FolderId | null) => boolean;
@@ -146,6 +165,25 @@ function buildTree(folders: FolderRecord[]): FolderTreeNode[] {
return build(ROOT_FOLDER_ID, 0);
}
/** The record a subdirectory of a mount presents as: kind local, path as id. */
function diskSubfolderRecord(
path: string,
name: string,
parentFolderId: FolderId,
): FolderRecord {
const now = Date.now();
return {
id: diskFolderId(path),
kind: "local",
name,
parentFolderId,
directory: path,
color: pickFolderColor(name),
createdAt: now,
updatedAt: now,
};
}
/** Convert a server-side error to a banner-ready user message. */
function formatServerError(err: unknown): string {
if (err && typeof err === "object" && "response" in err) {
@@ -241,7 +279,26 @@ function shouldStrandedReset(
}
export function FolderProvider({ children }: FolderProviderProps) {
const [folders, setFolders] = useState<FolderRecord[]>([]);
const [storedFolders, setFolders] = useState<FolderRecord[]>([]);
// Never persisted: a directory is its own record, so a listing rebuilds these.
const [diskSubfolders, setDiskSubfolders] = useState<
Map<FolderId, FolderRecord[]>
>(() => new Map());
const folders = useMemo(() => {
const known = new Set(storedFolders.map((f) => f.id));
const synthesized: FolderRecord[] = [];
for (const records of diskSubfolders.values()) {
for (const record of records) {
if (!known.has(record.id)) {
known.add(record.id);
synthesized.push(record);
}
}
}
return synthesized.length
? [...storedFolders, ...synthesized]
: storedFolders;
}, [storedFolders, diskSubfolders]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Start `false` so folder-mutation buttons are disabled until the first
@@ -269,9 +326,14 @@ export function FolderProvider({ children }: FolderProviderProps) {
const refresh = useCallback(async () => {
setLoading(true);
try {
const all = await folderStorage.getAllFolders();
// Three systems of record behind one list; kind says which rules a row follows.
const [server, virtual, local] = await Promise.all([
folderStorage.getAllFolders(),
virtualFolderStorage.getAllFolders(),
localFolderStorage.getAllFolders(),
]);
if (!mountedRef.current) return;
setFolders(all);
setFolders([...server, ...virtual, ...local]);
} catch (err) {
console.error("[FolderContext] cache read failed", err);
if (mountedRef.current) {
@@ -342,7 +404,11 @@ export function FolderProvider({ children }: FolderProviderProps) {
console.warn("[FolderContext] cache replace failed", cacheErr);
}
if (mountedRef.current) {
setFolders(remote);
// Server-wins is for server rows: the other kinds have no server copy.
setFolders((prev) => [
...remote,
...prev.filter((f) => folderKind(f) !== "server"),
]);
setServerReachable(true);
setError(null);
}
@@ -519,11 +585,65 @@ export function FolderProvider({ children }: FolderProviderProps) {
[bumpFolderRevision, folders, handleStaleFolder],
);
/** The kind of an existing folder, or throw — mutations must never guess. */
const requireKind = useCallback(
(id: FolderId): FolderKind => {
const folder = foldersById.get(id);
if (!folder) throw new Error(`Unknown folder: ${id}`);
return folderKind(folder);
},
[foldersById],
);
const createFolder = useCallback(
async (
name: string,
parentFolderId: FolderId | null = currentFolderId,
kind?: FolderKind,
): Promise<FolderRecord> => {
// A child's kind is its parent's: one subtree, one system of record.
const effectiveKind: FolderKind =
parentFolderId !== null
? requireKind(parentFolderId)
: (kind ?? "server");
if (effectiveKind === "local") {
// A mount's subfolder is a directory: make it on disk, present it as a
// listing would. Mount roots come from the picker, never here.
const parent = parentFolderId ? foldersById.get(parentFolderId) : null;
if (!parent?.directory) {
throw new Error("Cannot create a folder outside a mounted directory");
}
const path = await makeDiskDirectory(parent.directory, name);
if (path === null) {
throw new Error("This build cannot create folders on disk");
}
const record = diskSubfolderRecord(path, name, parent.id);
if (mountedRef.current) {
setDiskSubfolders((prev) => {
const next = new Map(prev);
const siblings = (next.get(parent.id) ?? []).filter(
(f) => f.id !== record.id,
);
next.set(parent.id, [...siblings, record]);
return next;
});
setError(null);
}
bumpFolderRevision();
return record;
}
if (effectiveKind === "virtual") {
const record = await virtualFolderStorage.createFolder(
name,
parentFolderId,
);
if (mountedRef.current) {
setFolders((prev) => [...prev, record]);
setError(null);
}
bumpFolderRevision();
return record;
}
const color = pickFolderColor(name);
// Client-side id makes server idempotency check safe on retry.
const id = createFolderId();
@@ -549,11 +669,41 @@ export function FolderProvider({ children }: FolderProviderProps) {
}
return result;
},
[currentFolderId, runFolderMutation],
[
currentFolderId,
requireKind,
storageBackedByServer,
bumpFolderRevision,
runFolderMutation,
],
);
/** Apply a mutated non-server record to state; the store already has it. */
const applyOwnedRecord = useCallback(
(record: FolderRecord | null): FolderRecord | null => {
if (record !== null && mountedRef.current) {
setFolders((prev) =>
prev.map((f) => (f.id === record.id ? record : f)),
);
setError(null);
}
bumpFolderRevision();
return record;
},
[bumpFolderRevision],
);
const renameFolder = useCallback(
async (id: FolderId, name: string) => {
const kind = requireKind(id);
if (kind === "local") {
throw new Error("A local folder takes its name from its directory");
}
if (kind === "virtual") {
return applyOwnedRecord(
await virtualFolderStorage.updateFolder(id, { name }),
);
}
return runFolderMutation(
() => folderSyncService.update(id, { name }),
async (record) => {
@@ -565,11 +715,23 @@ export function FolderProvider({ children }: FolderProviderProps) {
id,
);
},
[runFolderMutation],
[applyOwnedRecord, requireKind, runFolderMutation],
);
const moveFolder = useCallback(
async (id: FolderId, newParentId: FolderId | null) => {
const kind = requireKind(id);
if (newParentId !== null && requireKind(newParentId) !== kind) {
throw new Error("Folders can only move within their own kind");
}
if (kind === "local") {
throw new Error("A local folder sits where its directory sits");
}
if (kind === "virtual") {
return applyOwnedRecord(
await virtualFolderStorage.moveFolder(id, newParentId),
);
}
return runFolderMutation(
() =>
folderSyncService.update(id, {
@@ -585,7 +747,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
id,
);
},
[runFolderMutation],
[applyOwnedRecord, requireKind, runFolderMutation],
);
const updateFolderAppearance = useCallback(
@@ -593,6 +755,23 @@ export function FolderProvider({ children }: FolderProviderProps) {
id: FolderId,
appearance: { color?: string; icon?: string | null },
) => {
const kind = requireKind(id);
if (kind === "local") {
throw new Error("Local folders cannot be recoloured yet");
}
if (kind === "virtual") {
// Only the fields the picker sent: it sends one key per interaction, and the
// store's spread persists an explicit undefined, so passing both would erase
// the one the user did not touch. icon: null clears the icon, deliberately.
const updates: { color?: string; icon?: string } = {};
if (appearance.color !== undefined) updates.color = appearance.color;
if (appearance.icon !== undefined) {
updates.icon = appearance.icon ?? undefined;
}
return applyOwnedRecord(
await virtualFolderStorage.updateFolder(id, updates),
);
}
return runFolderMutation(
() =>
folderSyncService.update(id, {
@@ -608,11 +787,50 @@ export function FolderProvider({ children }: FolderProviderProps) {
id,
);
},
[runFolderMutation],
[applyOwnedRecord, requireKind, runFolderMutation],
);
const deleteFolder = useCallback(
async (id: FolderId): Promise<FolderId[]> => {
const kind = requireKind(id);
if (kind === "local") {
if (isDiskFolderId(id)) {
throw new Error(
"Subfolders of a mounted directory are removed on disk",
);
}
// Removes the record and nothing else; the directory is the user's.
await localFolderStorage.removeFolder(id);
if (mountedRef.current) {
setError(null);
setFolders((prev) => prev.filter((f) => f.id !== id));
if (currentFolderId === id) {
setCurrentFolderId(ROOT_FOLDER_ID);
}
}
bumpFolderRevision();
return [id];
}
if (kind === "virtual") {
// Same shape as the server path: subtree delete, strand-reset, detach files.
const removed = await virtualFolderStorage.deleteFolder(id);
const removedSet = new Set(removed);
if (mountedRef.current) {
setError(null);
setFolders((prev) => prev.filter((f) => !removedSet.has(f.id)));
if (
currentFolderId &&
shouldStrandedReset(currentFolderId, removedSet, folders)
) {
setCurrentFolderId(ROOT_FOLDER_ID);
}
}
bumpFolderRevision();
await clearFolderForFiles(removed).catch((e) =>
console.warn("[FolderContext] virtual folder file cleanup", e),
);
return removed;
}
// Custom path (not runFolderMutation) because we have two best-effort
// cleanups to coordinate, and need to reset currentFolderId BEFORE the
// cleanups so the user isn't stranded inside a tombstone if the cache
@@ -680,9 +898,94 @@ export function FolderProvider({ children }: FolderProviderProps) {
currentFolderId,
folders,
handleStaleFolder,
requireKind,
],
);
const mountLocalFolder = useCallback(
async (directory: string, name: string): Promise<FolderRecord> => {
const record = await localFolderStorage.mountDirectory(directory, name);
if (mountedRef.current) {
setError(null);
// Idempotent mount can hand back a record that's already listed.
setFolders((prev) =>
prev.some((f) => f.id === record.id) ? prev : [...prev, record],
);
}
bumpFolderRevision();
return record;
},
[bumpFolderRevision],
);
const registerDiskSubfolders = useCallback(
(parentId: FolderId, records: FolderRecord[]) => {
setDiskSubfolders((prev) => {
const before = prev.get(parentId) ?? [];
const same =
before.length === records.length &&
before.every(
(f, i) => f.id === records[i]?.id && f.name === records[i]?.name,
);
if (same) return prev;
const next = new Map(prev);
next.set(parentId, records);
return next;
});
},
[],
);
const resolveDiskFolder = useCallback(
(id: FolderId): boolean => {
const path = diskFolderPath(id);
if (path === null) return false;
const pathKey = directoryKey(path);
// The deepest mount containing the path: nested mounts give a shorter chain.
let mount: FolderRecord | null = null;
let mountKeyLength = -1;
for (const folder of storedFolders) {
if (folderKind(folder) !== "local" || !folder.directory) continue;
const key = directoryKey(folder.directory);
const prefix = key.endsWith("/") ? key : `${key}/`;
if (pathKey.startsWith(prefix) && key.length > mountKeyLength) {
mount = folder;
mountKeyLength = key.length;
}
}
if (!mount?.directory) return false;
// Rebuild every level between the mount and the path, each as a child
// of the one above, so breadcrumbs and the tree have the whole chain.
const sep = path.includes("\\") ? "\\" : "/";
const mountDir = mount.directory.replace(/[\\/]+$/, "");
const rest = path
.slice(mountDir.length)
.split(/[\\/]+/)
.filter(Boolean);
const additions: Array<[FolderId, FolderRecord]> = [];
let parentId: FolderId = mount.id;
let current = mountDir;
for (const segment of rest) {
current = `${current}${sep}${segment}`;
const record = diskSubfolderRecord(current, segment, parentId);
additions.push([parentId, record]);
parentId = record.id;
}
setDiskSubfolders((prev) => {
const next = new Map(prev);
for (const [parent, record] of additions) {
const siblings = next.get(parent) ?? [];
if (!siblings.some((f) => f.id === record.id)) {
next.set(parent, [...siblings, record]);
}
}
return next;
});
return true;
},
[storedFolders],
);
const value = useMemo<FolderContextValue>(
() => ({
folders,
@@ -698,6 +1001,9 @@ export function FolderProvider({ children }: FolderProviderProps) {
refresh,
pullFromServer,
createFolder,
mountLocalFolder,
registerDiskSubfolders,
resolveDiskFolder,
renameFolder,
moveFolder,
updateFolderAppearance,
@@ -717,6 +1023,9 @@ export function FolderProvider({ children }: FolderProviderProps) {
refresh,
pullFromServer,
createFolder,
mountLocalFolder,
registerDiskSubfolders,
resolveDiskFolder,
renameFolder,
moveFolder,
updateFolderAppearance,
@@ -273,6 +273,12 @@ interface AddFileOptions {
/** When true, marks every added stub as derivedFromTool so the policy
* auto-run skips it — used for policy outputs imported via addFiles. */
derivedFromTool?: boolean;
/**
* The folder every added file is born into — membership set at creation, atomically
* with the stub, instead of a separate move that can fail after the file already
* landed somewhere else.
*/
folderId?: string;
}
/**
@@ -444,6 +450,9 @@ export async function addFiles(
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
const fileStub = createNewStirlingFileStub(file, fileId);
if (options.derivedFromTool) fileStub.derivedFromTool = true;
if (options.folderId) {
fileStub.folderId = options.folderId as StirlingFileStub["folderId"];
}
// Early encryption detection for PDFs — set the flag before dispatch so the
// viewer gate and modal queue pick it up immediately instead of after hydration
@@ -17,6 +17,8 @@ export const useFileHandler = () => {
autoUnzip?: boolean;
/** Skip the upload metric - the file isn't new to the system (e.g. a copy). */
skipUploadTracking?: boolean;
/** Folder every added file is born into (see AddFileOptions). */
folderId?: string;
} = {},
): Promise<StirlingFile[]> => {
// Merge default options with passed options - passed options take precedence
@@ -3,6 +3,7 @@ import type { FileId } from "@app/types/file";
import { useFileManagement } from "@app/contexts/FileContext";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
import { readDiskFile } from "@app/services/localFolderContents";
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
@@ -15,7 +16,7 @@ const LAZY_THUMB_CONCURRENCY = 2;
let activeLazyThumbs = 0;
const lazyThumbQueue: Array<() => Promise<void>> = [];
function scheduleLazyThumb(task: () => Promise<void>): void {
export function scheduleLazyThumb(task: () => Promise<void>): void {
lazyThumbQueue.push(task);
drainLazyThumbQueue();
}
@@ -80,3 +81,92 @@ export function useLazyThumbnail(
return thumb;
}
// Keyed by path + mtime + size: an unchanged file never renders twice, an edited one does.
const diskThumbCache = new Map<string, string>();
// Bounded by bytes, not entries: image thumbnails are data URLs that track the
// source, so 300 photos would pin gigabytes of strings for the process lifetime.
const DISK_THUMB_CACHE_MAX_BYTES = 48 * 1024 * 1024;
let diskThumbCacheBytes = 0;
function cacheDiskThumb(key: string, url: string): void {
const prior = diskThumbCache.get(key);
if (prior !== undefined) diskThumbCacheBytes -= prior.length;
while (
diskThumbCacheBytes + url.length > DISK_THUMB_CACHE_MAX_BYTES &&
diskThumbCache.size > 0
) {
// Insertion order makes this FIFO; an evicted thumbnail re-renders on revisit.
const oldest = diskThumbCache.keys().next().value!;
diskThumbCacheBytes -= diskThumbCache.get(oldest)!.length;
diskThumbCache.delete(oldest);
}
diskThumbCache.set(key, url);
diskThumbCacheBytes += url.length;
}
// Reading the bytes is the expensive step, so only for types the generator renders.
const THUMBABLE_EXTENSIONS = new Set([
"pdf",
"png",
"jpg",
"jpeg",
"gif",
"webp",
"bmp",
"svg",
]);
function canEverThumbnail(name: string): boolean {
const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
return THUMBABLE_EXTENSIONS.has(ext);
}
/**
* Thumbnail for a disk-listed file, through the same generator and the same concurrency
* gate as stored files — a mounted folder's rows fill in progressively alongside
* everything else instead of stampeding the disk.
*/
export function useDiskThumbnail(entry: {
path: string;
name: string;
sizeBytes: number;
lastModified: number;
}): string | undefined {
const key = `${entry.path}|${entry.lastModified}|${entry.sizeBytes}`;
const [thumb, setThumb] = useState<string | undefined>(() => {
const hit = diskThumbCache.get(key);
return hit === "" ? undefined : hit;
});
useEffect(() => {
const cached = diskThumbCache.get(key);
if (cached !== undefined) {
setThumb(cached === "" ? undefined : cached);
return;
}
if (entry.sizeBytes >= THUMBNAIL_SIZE_LIMIT) return;
if (!canEverThumbnail(entry.name)) return;
let cancelled = false;
scheduleLazyThumb(async () => {
if (cancelled || diskThumbCache.has(key)) return;
try {
const file = await readDiskFile(entry);
if (!file || cancelled) return;
const url = await generateThumbnailForFile(file);
// "" is cached too: a failed/oversized render should not retry on
// every re-mount of the same row.
cacheDiskThumb(key, url);
if (!cancelled && url) setThumb(url);
} catch {
cacheDiskThumb(key, "");
}
});
return () => {
cancelled = true;
};
// The key encodes every field of `entry` this effect reads.
}, [key]);
return thumb;
}
@@ -0,0 +1,70 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useFolders } from "@app/contexts/FolderContext";
import { useFilesPage } from "@app/contexts/FilesPageContext";
import { canPickDirectory, pickDirectory } from "@app/services/directoryPicker";
import { useServerFolderBlock } from "@app/hooks/useServerFolderBlock";
/** The folder-creation flows, shared by every surface that offers them so they
* cannot drift apart. */
export function useNewFolderFlow() {
const { t } = useTranslation();
const folders = useFolders();
const { openNewFolderDialog } = useFilesPage();
const navigate = useNavigate();
const serverFolderBlock = useServerFolderBlock();
// No dialog: the picker is the whole interaction and the directory names the folder.
const addLocalFolder = useCallback(async () => {
try {
const picked = await pickDirectory();
if (!picked) return;
const record = await folders.mountLocalFolder(picked.path, picked.name);
// The path owns folder selection: setting state here races the effect that
// re-runs with the old pathname and snaps back to root.
navigate(`/files/${record.id}`);
} catch (err) {
folders.setError(
err instanceof Error
? t("filesPage.error.addFolderFailedDetail", {
message: err.message,
defaultValue: `Could not add the folder: ${err.message}`,
})
: t("filesPage.error.addFolderFailed", "Could not add the folder."),
);
}
}, [folders, navigate, t]);
// Single-click New folder for surfaces with no menu: the picker where the build
// can see the disk, a server folder on the web, and blocked rather than silent
// when the server cannot take one. Inside a folder the kind is inherited.
const createFolderHere = useCallback(() => {
if (folders.currentFolderId !== null) {
openNewFolderDialog(folders.currentFolderId);
return;
}
if (canPickDirectory) {
void addLocalFolder();
return;
}
// Backstop: surfaces disable themselves, so a click here means stale UI.
if (serverFolderBlock === null) {
openNewFolderDialog(null, "server");
}
}, [
addLocalFolder,
folders.currentFolderId,
openNewFolderDialog,
serverFolderBlock,
]);
// Why the single-click surfaces are disabled, or null. Only the web root blocks:
// desktop always has the picker, and subfolders inherit their kind.
const createFolderHereBlockedReason =
folders.currentFolderId === null && !canPickDirectory
? serverFolderBlock
: null;
return { addLocalFolder, createFolderHere, createFolderHereBlockedReason };
}
@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useFolders } from "@app/contexts/FolderContext";
/** Why a server folder can't be created right now, or null when it can. */
export function useServerFolderBlock(): string | null {
const { t } = useTranslation();
const { isAnonymous } = useAuth();
const { config: appConfig } = useAppConfig();
const folders = useFolders();
if (isAnonymous) {
return t("filesPage.signInRequired", "Sign in to use cloud storage.");
}
// Two different problems, two different next steps: storage off is an
// admin setting; unreachable is a connectivity state that fixes itself.
if (appConfig?.storageEnabled !== true) {
return t(
"filesPage.newFolderStorageDisabled",
"Server folder storage isn't enabled.",
);
}
if (!folders.serverReachable) {
return t("filesPage.syncError.network", "Could not reach the server.");
}
return null;
}
+18 -6
View File
@@ -56,6 +56,9 @@ import {
useFilesPage,
} from "@app/contexts/FilesPageContext";
import { useFolders } from "@app/contexts/FolderContext";
import { folderKind } from "@app/types/folder";
import { useServerFolderBlock } from "@app/hooks/useServerFolderBlock";
import { useNewFolderFlow } from "@app/hooks/useNewFolderFlow";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
@@ -771,6 +774,8 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
const filesPage = useFilesPage();
const folders = useFolders();
const { addFiles } = useFileHandler();
const { createFolderHere, createFolderHereBlockedReason } =
useNewFolderFlow();
const handleUpload = useCallback(
async (files: File[]) => {
@@ -787,12 +792,19 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
[addFiles, filesPage, folders.currentFolderId],
);
const newFolderDisabledReason = !folders.serverReachable
? t(
"filesPage.newFolderStorageDisabled",
"Server folder storage isn't enabled. Ask your admin to turn it on.",
)
// Kind-aware: only a server folder's subfolder needs the server, and a mounted
// directory takes no subfolders from here at all.
const railCurrentFolder = folders.currentFolderId
? folders.foldersById.get(folders.currentFolderId)
: undefined;
const railCurrentKind = railCurrentFolder
? folderKind(railCurrentFolder)
: null;
const serverFolderBlock = useServerFolderBlock();
const newFolderDisabledReason =
railCurrentKind === "server"
? serverFolderBlock
: createFolderHereBlockedReason;
return (
<FileSidebar
@@ -803,7 +815,7 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
extraAction={{
icon: <CreateNewFolderIcon />,
label: t("filesPage.newFolder", "New folder"),
onClick: () => filesPage.openNewFolderDialog(),
onClick: createFolderHere,
disabled: newFolderDisabledReason !== null,
disabledTooltip: newFolderDisabledReason ?? undefined,
testId: "files-rail-new-folder",
@@ -0,0 +1,15 @@
/** Picking a directory on the machine, as a real filesystem path. */
export interface PickedDirectory {
/** Absolute path, as the platform writes it. */
path: string;
/** The directory's own name — the mounted folder's display name. */
name: string;
}
export const canPickDirectory = false;
/** Ask the user for a directory; null when cancelled (or unsupported). */
export async function pickDirectory(): Promise<PickedDirectory | null> {
return null;
}
@@ -11,6 +11,7 @@ import { alert } from "@app/components/toast";
import { StirlingFileStub, StirlingFile } from "@app/types/fileContext";
import { FileId } from "@app/types/fileContext";
import { FolderId, parseFolderId } from "@app/types/folder";
import { virtualFolderStorage } from "@app/services/virtualFolderStorage";
import {
isZipBundle,
loadShareBundleEntries,
@@ -119,6 +120,15 @@ export async function reconcileServerFiles(
}
let combinedStubs: StirlingFileStub[];
// Virtual folders are browser-owned, so a stub sitting in one must keep its
// membership through the reconcile — the server's folderId (always null for them) is
// not an opinion about it.
const virtualFolderIds = new Set<FolderId>(
await virtualFolderStorage
.getAllFolders()
.then((folders) => folders.map((folder) => folder.id))
.catch(() => []),
);
const localRemoteIds = new Set(
localStubs
.map((s) => s.remoteStorageId)
@@ -202,7 +212,12 @@ export async function reconcileServerFiles(
// Server is authoritative for cloud-stored files. Don't fall back to
// stub.folderId on null - that would resurrect a stale folder pointer
// after the server SET_NULL'd it (e.g. owner deleted the folder).
folderId: safeParseFolderId(serverFile.folderId),
// EXCEPT when the stub sits in a browser-owned (virtual) folder: the
// server has never heard of that folder, so its null says nothing
// about the membership and must not eject the file from it.
folderId: virtualFolderIds.has((stub.folderId ?? "") as FolderId)
? stub.folderId
: safeParseFolderId(serverFile.folderId),
};
});
@@ -11,12 +11,24 @@
* are all the server's job now.
*/
import { FolderId, FolderRecord } from "@app/types/folder";
import { FolderId, FolderRecord, folderKind } from "@app/types/folder";
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
/**
* This cache is wiped and rewritten from the server's response on every sync, so a
* non-server folder stored here would silently vanish on the next pull.
*/
function requireServerFolder(folder: FolderRecord): void {
if (folderKind(folder) !== "server") {
throw new Error(
`folderStorage caches server folders only; got kind "${folderKind(folder)}" for ${folder.id}`,
);
}
}
class FolderStorageService {
private readonly dbConfig = DATABASE_CONFIGS.FILES;
private readonly storeName = "folders";
@@ -43,6 +55,7 @@ class FolderStorageService {
reject(transaction.error ?? new Error("folder cache replace aborted"));
store.clear();
for (const folder of folders) {
requireServerFolder(folder);
store.put(folder);
}
});
@@ -50,6 +63,7 @@ class FolderStorageService {
/** Insert or overwrite a single folder in the cache. */
async upsertFolder(folder: FolderRecord): Promise<void> {
requireServerFolder(folder);
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
@@ -51,6 +51,9 @@ function toFolderRecord(dto: ServerFolder): FolderRecord {
dto.parentFolderId === null ? null : parseFolderId(dto.parentFolderId);
return {
id,
// Everything that comes off this wire is a server folder by definition;
// virtual and local folders never round-trip through the server at all.
kind: "server",
name: dto.name,
parentFolderId,
color: dto.color ?? undefined,
@@ -399,4 +399,71 @@ describe("IndexedDB migration (FILES store)", () => {
TARGET_VERSION,
);
});
test("a v10 profile missing local_folders upgrades to v11 with the full schema", async () => {
// v10 briefly existed with only one of the two browser-folder stores.
await new Promise<void>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 10);
req.onupgradeneeded = () => {
const db = req.result;
db.createObjectStore("files", { keyPath: "id" });
db.createObjectStore("folders", { keyPath: "id" });
db.createObjectStore("virtual_folders", { keyPath: "id" });
// local_folders deliberately absent.
};
req.onsuccess = () => {
req.result.close();
resolve();
};
req.onerror = () => reject(req.error);
});
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
const names = Array.from(db.objectStoreNames);
expect(names).toContain("local_folders");
expect(names).toContain("virtual_folders");
expect(db.version).toBe(TARGET_VERSION);
indexedDBManager.closeDatabase(DB_NAME);
});
test("v9 -> latest adds virtual_folders without touching files or folders", async () => {
// Seed a database shaped like the v9 schema: files + folders, no
// virtual_folders yet, with a row in each that must survive the upgrade.
await new Promise<void>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 9);
req.onupgradeneeded = () => {
const db = req.result;
db.createObjectStore("files", { keyPath: "id" });
db.createObjectStore("folders", { keyPath: "id" });
};
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction(["files", "folders"], "readwrite");
tx.objectStore("files").put({ id: "file-1", folderId: null });
tx.objectStore("folders").put({ id: "folder-1", name: "Kept" });
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => reject(tx.error);
};
req.onerror = () => reject(req.error);
});
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
indexedDBManager.closeDatabase(DB_NAME);
const stores = await getObjectStoreNames();
expect(stores).toContain("virtual_folders");
expect(stores).toContain("local_folders");
expect(stores).toContain("files");
expect(stores).toContain("folders");
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
expect(rows.map((row) => row.id)).toEqual(["file-1"]);
expect(await indexedDBManager.getDatabaseVersion(DB_NAME)).toBe(
TARGET_VERSION,
);
});
});
@@ -465,7 +465,9 @@ class IndexedDBManager {
export const DATABASE_CONFIGS = {
FILES: {
name: "stirling-pdf-files",
version: 9,
// v10 existed briefly with only one of the two browser-folder stores; v11 declares
// both, so every v10 profile upgrades to a full schema.
version: 11,
stores: [
{
name: "files",
@@ -492,6 +494,27 @@ export const DATABASE_CONFIGS = {
{ name: "createdAt", keyPath: "createdAt", unique: false },
],
},
{
name: "local_folders",
keyPath: "id",
indexes: [{ name: "name", keyPath: "name", unique: false }],
},
// Browser-owned folders (kind "virtual"), deliberately a separate store from
// `folders`: that one is a cache the server sync wipes wholesale on every pull,
// and these rows have no server copy to be restored from.
{
name: "virtual_folders",
keyPath: "id",
indexes: [
{
name: "parentFolderId",
keyPath: "parentFolderId",
unique: false,
},
{ name: "name", keyPath: "name", unique: false },
{ name: "createdAt", keyPath: "createdAt", unique: false },
],
},
],
} as DatabaseConfig,
@@ -0,0 +1,54 @@
/** One file inside a mounted directory, as the file manager lists it. */
export interface DiskFileEntry {
/** Absolute path — the file's identity here; nothing about it is stored. */
path: string;
name: string;
sizeBytes: number;
lastModified: number;
}
export interface DiskDirEntry {
path: string;
name: string;
}
/** What one look at a mounted directory yields. */
export interface DiskListing {
files: DiskFileEntry[];
directories: DiskDirEntry[];
}
export const canListDirectory = false;
/**
* The regular files and subdirectories directly inside `directory` — one level, never
* recursive; a subdirectory is listed only when entered.
*/
export async function listDirectory(
_directory: string,
): Promise<DiskListing | null> {
return null;
}
export async function makeDiskDirectory(
_parent: string,
_name: string,
): Promise<string | null> {
return null;
}
/** Read one listed file's bytes as a File, ready for the workbench. */
export async function readDiskFile(
_entry: DiskFileEntry,
): Promise<File | null> {
return null;
}
/** Write a file into a mounted directory, under a name that never clobbers an existing one. */
export async function writeDiskFile(
_directory: string,
_name: string,
_bytes: Blob,
): Promise<string | null> {
return null;
}
@@ -0,0 +1,64 @@
import { describe, expect, test, beforeEach } from "vitest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import {
localFolderStorage,
directoryKey,
} from "@app/services/localFolderStorage";
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
/**
* A mount is a pointer at a directory, and one directory must never have two pointers —
* the rows would be two names for one truth.
*/
describe("localFolderStorage", () => {
beforeEach(() => {
indexedDBManager.closeDatabase(DATABASE_CONFIGS.FILES.name);
globalThis.indexedDB = new IDBFactory();
});
test("directoryKey equates the spellings a case-insensitive filesystem does", () => {
const key = directoryKey("C:\\Users\\Reece\\Downloads");
expect(directoryKey("c:\\users\\reece\\downloads")).toBe(key);
expect(directoryKey("C:\\Users\\Reece\\Downloads\\")).toBe(key);
expect(directoryKey("C:/Users/Reece/Downloads")).toBe(key);
expect(directoryKey("C:\\Users\\\\Reece\\Downloads")).toBe(key);
expect(directoryKey("\\\\server\\share\\docs")).toBe(
directoryKey("//SERVER/share/docs/"),
);
// POSIX paths are genuinely case-sensitive; only the separator rules apply.
expect(directoryKey("/home/Reece/")).toBe(directoryKey("/home/Reece"));
expect(directoryKey("/home/Reece")).not.toBe(directoryKey("/home/reece"));
});
test("mounting the same directory under another spelling hands back the existing record", async () => {
const first = await localFolderStorage.mountDirectory(
"C:\\Users\\Reece\\Downloads",
"Downloads",
);
const again = await localFolderStorage.mountDirectory(
"c:/users/reece/downloads/",
"downloads",
);
expect(again.id).toBe(first.id);
expect(await localFolderStorage.getAllFolders()).toHaveLength(1);
});
test("a subdirectory of a mount gets its own mount; that is the only way to reach it", async () => {
const parent = await localFolderStorage.mountDirectory(
"C:\\Users\\Reece\\Downloads",
"Downloads",
);
const child = await localFolderStorage.mountDirectory(
"C:\\Users\\Reece\\Downloads\\Invoices",
"Invoices",
);
expect(child.id).not.toBe(parent.id);
expect(await localFolderStorage.getAllFolders()).toHaveLength(2);
});
});
@@ -0,0 +1,119 @@
/**
* The record of directories mounted into the file manager (kind "local"): a pointer at
* a directory, nothing more.
*/
import {
FolderId,
FolderRecord,
folderKind,
createFolderId,
pickFolderColor,
} from "@app/types/folder";
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
/** One directory, one key — regardless of how the picker spelled the path. */
export function directoryKey(directory: string): string {
let key = directory.replace(/\\/g, "/");
const unc = key.startsWith("//");
key = key.replace(/\/{2,}/g, "/");
if (unc) key = `/${key}`;
if (key.length > 1 && !/^[a-zA-Z]:\/$/.test(key)) {
key = key.replace(/\/+$/, "");
}
if (/^[a-zA-Z]:/.test(key) || unc) {
key = key.toLowerCase();
}
return key;
}
function requireLocalFolder(folder: FolderRecord): void {
if (folderKind(folder) !== "local") {
throw new Error(
`localFolderStorage owns local folders only; got kind "${folderKind(folder)}" for ${folder.id}`,
);
}
}
class LocalFolderStorageService {
private readonly dbConfig = DATABASE_CONFIGS.FILES;
private readonly storeName = "local_folders";
private async getDatabase(): Promise<IDBDatabase> {
return indexedDBManager.openDatabase(this.dbConfig);
}
async getAllFolders(): Promise<FolderRecord[]> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () =>
resolve((request.result as FolderRecord[]) ?? []);
});
}
/**
* Mount a directory, or hand back the existing record for one already mounted: two
* rows for one directory would be two names for one truth.
*/
async mountDirectory(directory: string, name: string): Promise<FolderRecord> {
const key = directoryKey(directory);
const folders = await this.getAllFolders();
const existing = folders.find(
(folder) => directoryKey(folder.directory ?? "") === key,
);
if (existing) return existing;
const now = Date.now();
const record: FolderRecord = {
id: createFolderId(),
kind: "local",
name,
parentFolderId: null,
directory,
color: pickFolderColor(name),
createdAt: now,
updatedAt: now,
};
requireLocalFolder(record);
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const req = store.put(record);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve();
});
return record;
}
/** Remove the mount. The directory on disk is untouched, always. */
async removeFolder(id: FolderId): Promise<void> {
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const req = store.delete(id);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve();
});
}
async clearAll(): Promise<void> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
}
export const localFolderStorage = new LocalFolderStorageService();
@@ -0,0 +1,38 @@
import { writeDiskFile } from "@app/services/localFolderContents";
export interface MountWriteItem {
name: string;
/** Pulled lazily, one file at a time — a batch never sits in memory whole. */
bytes: () => Promise<Blob | null>;
}
/**
* Write named blobs into a mounted directory - the one engine behind both uploading
* into a mount and moving library files into one, so failure accounting and collision
* behaviour cannot drift apart.
*/
export async function writeIntoMount(
directory: string | undefined,
items: MountWriteItem[],
): Promise<{ written: boolean[]; failedCount: number }> {
const written: boolean[] = items.map(() => false);
let failedCount = 0;
for (let i = 0; i < items.length; i++) {
try {
const blob = directory ? await items[i].bytes() : null;
const result =
blob && directory
? await writeDiskFile(directory, items[i].name, blob)
: null;
if (result === null) {
failedCount += 1;
} else {
written[i] = true;
}
} catch (err) {
console.warn("[mountWrites] write into mount failed", err);
failedCount += 1;
}
}
return { written, failedCount };
}
@@ -0,0 +1,78 @@
import { describe, expect, test, beforeEach } from "vitest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { virtualFolderStorage } from "@app/services/virtualFolderStorage";
import { folderStorage } from "@app/services/folderStorage";
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
/**
* Virtual folders have no server to be authoritative, so the invariants the
* server enforces for its folders (no cycles, bounded depth, subtree deletes
* that report every removed id) are this module's own responsibility.
*/
describe("virtualFolderStorage", () => {
beforeEach(() => {
indexedDBManager.closeDatabase(DATABASE_CONFIGS.FILES.name);
globalThis.indexedDB = new IDBFactory();
});
test("creates rows stamped virtual, invisible to the server folder cache", async () => {
const created = await virtualFolderStorage.createFolder("Research", null);
expect(created.kind).toBe("virtual");
// Same DB, different store: the server cache must not see it, because a
// sync wipes that cache wholesale and would silently destroy the row.
expect(await folderStorage.getAllFolders()).toEqual([]);
expect(await virtualFolderStorage.getAllFolders()).toEqual([created]);
});
test("the server cache refuses a virtual row outright", async () => {
const virtual = await virtualFolderStorage.createFolder("Research", null);
await expect(folderStorage.upsertFolder(virtual)).rejects.toThrow(
/server folders only/,
);
});
test("refuses to move a folder into its own subtree", async () => {
const parent = await virtualFolderStorage.createFolder("a", null);
const child = await virtualFolderStorage.createFolder("b", parent.id);
const grandchild = await virtualFolderStorage.createFolder("c", child.id);
await expect(
virtualFolderStorage.moveFolder(parent.id, grandchild.id),
).rejects.toThrow(/own subtree/);
await expect(
virtualFolderStorage.moveFolder(parent.id, parent.id),
).rejects.toThrow(/into itself/);
});
test("deleting a folder removes its whole subtree and reports every id", async () => {
const parent = await virtualFolderStorage.createFolder("a", null);
const child = await virtualFolderStorage.createFolder("b", parent.id);
const grandchild = await virtualFolderStorage.createFolder("c", child.id);
const bystander = await virtualFolderStorage.createFolder("keep", null);
const removed = await virtualFolderStorage.deleteFolder(parent.id);
// Every removed id is reported so the caller can unlink files that
// referenced them — the same contract as the server delete.
expect([...removed].sort()).toEqual(
[parent.id, child.id, grandchild.id].sort(),
);
expect(await virtualFolderStorage.getAllFolders()).toEqual([bystander]);
});
test("refuses to nest past the depth cap", async () => {
let parentId = (await virtualFolderStorage.createFolder("d0", null)).id;
for (let i = 1; i < 64; i += 1) {
parentId = (await virtualFolderStorage.createFolder(`d${i}`, parentId))
.id;
}
await expect(
virtualFolderStorage.createFolder("too-deep", parentId),
).rejects.toThrow(/depth limit/);
});
});
@@ -0,0 +1,219 @@
/**
* The system of record for kind "virtual" folders - rows this store owns rather than
* caches.
*/
import {
FolderId,
FolderRecord,
folderKind,
createFolderId,
pickFolderColor,
} from "@app/types/folder";
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
/** Mirrors FolderService.MAX_FOLDER_DEPTH so virtual trees can't out-nest server ones. */
const MAX_FOLDER_DEPTH = 64;
function requireVirtualFolder(folder: FolderRecord): void {
if (folderKind(folder) !== "virtual") {
throw new Error(
`virtualFolderStorage owns virtual folders only; got kind "${folderKind(folder)}" for ${folder.id}`,
);
}
}
class VirtualFolderStorageService {
private readonly dbConfig = DATABASE_CONFIGS.FILES;
private readonly storeName = "virtual_folders";
private async getDatabase(): Promise<IDBDatabase> {
return indexedDBManager.openDatabase(this.dbConfig);
}
async getAllFolders(): Promise<FolderRecord[]> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () =>
resolve((request.result as FolderRecord[]) ?? []);
});
}
async getFolder(id: FolderId): Promise<FolderRecord | null> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = () =>
resolve((request.result as FolderRecord | undefined) ?? null);
});
}
/**
* Create a virtual folder under `parent` (null = root), which must itself be
* virtual: hung off a server folder, a server-side delete orphans the subtree.
*/
async createFolder(
name: string,
parentFolderId: FolderId | null,
): Promise<FolderRecord> {
if (parentFolderId !== null) {
await this.requireWithinDepth(parentFolderId);
}
const now = Date.now();
const record: FolderRecord = {
id: createFolderId(),
kind: "virtual",
name,
parentFolderId,
color: pickFolderColor(name),
createdAt: now,
updatedAt: now,
};
await this.put(record);
return record;
}
/** Rename / recolour / re-icon in place. Structure is moveFolder's job. */
async updateFolder(
id: FolderId,
updates: Partial<Pick<FolderRecord, "name" | "color" | "icon">>,
): Promise<FolderRecord | null> {
const existing = await this.getFolder(id);
if (!existing) return null;
const next: FolderRecord = {
...existing,
...updates,
updatedAt: Date.now(),
};
await this.put(next);
return next;
}
/**
* Reparent a folder (null = to root), refusing moves that would make the
* tree lie: under itself or its own descendant (a cycle — the subtree would
* fall out of every walk), or deeper than the depth cap.
*/
async moveFolder(
id: FolderId,
newParentId: FolderId | null,
): Promise<FolderRecord | null> {
const existing = await this.getFolder(id);
if (!existing) return null;
if (newParentId !== null) {
if (newParentId === id) {
throw new Error("Cannot move a folder into itself");
}
const ancestors = await this.requireWithinDepth(newParentId);
if (ancestors.has(id)) {
throw new Error("Cannot move a folder into its own subtree");
}
}
const next: FolderRecord = {
...existing,
parentFolderId: newParentId,
updatedAt: Date.now(),
};
await this.put(next);
return next;
}
/**
* Delete a folder and its whole virtual subtree, returning every removed id
* so the caller can unlink files that referenced them — mirroring the shape
* of the server delete, which reports removedFolderIds for the same reason.
*/
async deleteFolder(id: FolderId): Promise<FolderId[]> {
const all = await this.getAllFolders();
const childrenByParent = new Map<FolderId | null, FolderRecord[]>();
for (const folder of all) {
const siblings = childrenByParent.get(folder.parentFolderId) ?? [];
siblings.push(folder);
childrenByParent.set(folder.parentFolderId, siblings);
}
// `removed` doubles as the BFS queue (index-walked; shift() would
// reindex the array on every visit).
const removed: FolderId[] = [id];
for (let head = 0; head < removed.length; head++) {
for (const child of childrenByParent.get(removed[head]) ?? []) {
removed.push(child.id);
}
}
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
transaction.oncomplete = () => resolve();
transaction.onerror = () =>
reject(transaction.error ?? new Error("virtual folder delete failed"));
transaction.onabort = () =>
reject(transaction.error ?? new Error("virtual folder delete aborted"));
for (const folderId of removed) store.delete(folderId);
});
return removed;
}
async clearAll(): Promise<void> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
private async put(record: FolderRecord): Promise<void> {
requireVirtualFolder(record);
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const req = store.put(record);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve();
});
}
/** Walk from `startId` to the root, returning the ids seen. */
private async requireWithinDepth(startId: FolderId): Promise<Set<FolderId>> {
// One read for the whole store, walked in memory — the chain would
// otherwise cost a serialized IndexedDB round trip per ancestor.
const byId = new Map(
(await this.getAllFolders()).map((folder) => [folder.id, folder]),
);
const seen = new Set<FolderId>();
let cursor: FolderId | null = startId;
while (cursor !== null) {
if (seen.has(cursor)) {
throw new Error("Virtual folder hierarchy contains a cycle");
}
seen.add(cursor);
const parent = byId.get(cursor);
if (parent === undefined) {
throw new Error(`No virtual folder: ${cursor}`);
}
cursor = parent.parentFolderId;
}
// The chain walked is the prospective parent's own ancestry; whatever is
// being placed under it sits one level deeper, so a full-depth chain has
// no room for a child.
if (seen.size >= MAX_FOLDER_DEPTH) {
throw new Error(`Folder depth limit reached (max ${MAX_FOLDER_DEPTH})`);
}
return seen;
}
}
export const virtualFolderStorage = new VirtualFolderStorageService();
+9 -6
View File
@@ -65,13 +65,16 @@ export interface BaseFileMetadata {
sourceFileIds?: FileId[];
/**
* The cloud folder this file lives in. Semantics:
* - `remoteStorageId == null` → file is local-only; folderId MUST be null.
* - `remoteStorageId != null && folderId == null` → file is at the cloud root.
* - `remoteStorageId != null && folderId == X` → file lives in cloud folder X.
* The folder this file lives in. Semantics by storage state:
* - Cloud-stored (`remoteStorageId != null`): server-authoritative — null is
* the cloud root, X is server folder X; the sync overwrites it.
* - Local-only: browser-owned — a browser folder's id, or the server folder
* the file was placed in at upload time, which membership the server
* adopts once the save-to-server lands (and which keeps the file visibly
* in its folder if that save fails).
*
* The "Local" pseudo-folder in the UI is the predicate `remoteStorageId == null`;
* it has no corresponding {@code folderId} value. Folders are a server-only concept.
* The "Local" pseudo-folder in the UI is the predicate
* `remoteStorageId == null && folderId == null`.
*/
folderId?: FolderId | null;
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import {
diskFolderId,
diskFolderPath,
isDiskFolderId,
} from "@app/types/folder";
/**
* A mount's subdirectories have no stored record, so their ids must carry the
* path itself — through a URL, across a reload, for any spelling the OS uses.
*/
describe("disk folder ids", () => {
test("round-trips Windows, POSIX, and non-ASCII paths", () => {
for (const path of [
"C:\\Users\\Reece\\Downloads\\Invoices",
"/home/reece/Documents/Rechnungen 2026",
"D:\\\u041f\u0440\u043e\u0435\u043a\u0442\u044b\\\u0421\u0447\u0435\u0442\u0430",
]) {
const id = diskFolderId(path);
expect(isDiskFolderId(id)).toBe(true);
expect(diskFolderPath(id)).toBe(path);
}
});
test("is URL-safe and distinct from stored folder ids", () => {
const id = diskFolderId("C:\\a+b/c?d");
expect(id).toMatch(/^disk:[A-Za-z0-9_-]+$/);
expect(isDiskFolderId("3f0e2a9c-0000-4000-8000-000000000000")).toBe(false);
expect(diskFolderPath("not-a-disk-id")).toBeNull();
});
});
+49
View File
@@ -37,11 +37,21 @@ export const FOLDER_COLOR_PALETTE = [
/** Members of {@link FOLDER_COLOR_PALETTE}. Use this rather than `string` to keep callers honest. */
export type FolderPaletteColor = (typeof FOLDER_COLOR_PALETTE)[number];
/**
* Three independent features that share a shape, not variants of one: - `server`: in
* the server's database, synced down and cached; needs login and storage to exist.
*/
export type FolderKind = "server" | "virtual" | "local";
/** Persisted folder shape stored in IndexedDB. */
export interface FolderRecord {
id: FolderId;
/** Read through {@link folderKind}, never directly. */
kind?: FolderKind;
name: string;
parentFolderId: FolderId | null;
/** For `local` folders: the directory this record mounts. */
directory?: string;
/** Hex colour - either a palette member or any custom hex from a future picker. */
color?: string;
icon?: string;
@@ -49,6 +59,11 @@ export interface FolderRecord {
updatedAt: number;
}
/** Absent means `server`: server DTOs and rows predating kinds never carry one. */
export function folderKind(folder: Pick<FolderRecord, "kind">): FolderKind {
return folder.kind ?? "server";
}
/**
* Folder tree node - derived from FolderRecord[] for rendering the tree
* navigator. Children are ordered by name (case-insensitive).
@@ -82,6 +97,40 @@ export function parseFolderId(value: unknown): FolderId {
return value as FolderId;
}
/** Subdirectories of a mounted folder are not stored anywhere — the directory is the record. */
const DISK_FOLDER_ID_PREFIX = "disk:";
export function diskFolderId(path: string): FolderId {
const bytes = new TextEncoder().encode(path);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
const b64 = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return `${DISK_FOLDER_ID_PREFIX}${b64}` as FolderId;
}
export function isDiskFolderId(id: string): boolean {
return id.startsWith(DISK_FOLDER_ID_PREFIX);
}
/** The path a disk subfolder id encodes, or null for any other id. */
export function diskFolderPath(id: string): string | null {
if (!isDiskFolderId(id)) return null;
const b64 = id
.slice(DISK_FOLDER_ID_PREFIX.length)
.replace(/-/g, "+")
.replace(/_/g, "/");
try {
const binary = atob(b64);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
} catch {
return null;
}
}
export function createFolderId(): FolderId {
return generateId() as FolderId;
}
@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connectionModeService } from "@app/services/connectionModeService";
import type { ConnectionMode } from "@app/services/connectionModeService";
import { useServerFolderBlock as useCoreServerFolderBlock } from "@core/hooks/useServerFolderBlock";
/**
* Desktop's blocker speaks in connection modes: local mode has no server at all, so
* "storage isn't enabled" would send the user after a setting that does not exist.
*/
export function useServerFolderBlock(): string | null {
const { t } = useTranslation();
const coreReason = useCoreServerFolderBlock();
// Seeded from the cache so a remount answers on its first frame; the effect covers
// the first-ever load and later mode switches.
const [mode, setMode] = useState<ConnectionMode | null>(() =>
connectionModeService.getCachedMode(),
);
useEffect(() => {
let mounted = true;
void connectionModeService.getCurrentMode().then((current) => {
if (mounted) setMode(current);
});
const unsubscribe = connectionModeService.subscribeToModeChanges((config) =>
setMode(config.mode),
);
return () => {
mounted = false;
unsubscribe();
};
}, []);
// While the mode is unknown (first-ever load), fail closed with the same message: in
// any mode where the item would be blocked, signing in or connecting a server IS the
// way out — unlike the core reasons, which in local mode point at a storage setting
// that doesn't exist.
if (mode === "local" || mode === null) {
return t(
"filesPage.serverFolderNeedsConnection",
"Sign in to Stirling Cloud or connect a self-hosted server to use server folders.",
);
}
return coreReason;
}
@@ -71,6 +71,11 @@ export class ConnectionModeService {
return config.mode;
}
/** The mode already in memory, or null before the first load. */
getCachedMode(): ConnectionMode | null {
return this.currentConfig?.mode ?? null;
}
async getServerConfig(): Promise<ServerConfig | null> {
const config = await this.getCurrentConfig();
return config.server_config;
@@ -0,0 +1,27 @@
/**
* Desktop directory picking: the Tauri file dialog hands back a real path,
* which is the whole reason local folders are a desktop capability — a
* browser can only produce handles, never locations.
*/
import { isTauri } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import type { PickedDirectory } from "@core/services/directoryPicker";
export type { PickedDirectory };
// The desktop bundle also runs as a plain web page in dev; only the actual
// Tauri webview can open the native dialog.
export const canPickDirectory = isTauri();
export async function pickDirectory(): Promise<PickedDirectory | null> {
if (!canPickDirectory) return null;
const picked = await open({ directory: true, multiple: false });
if (typeof picked !== "string" || picked.length === 0) return null;
// The path's last segment, tolerant of either separator and a trailing one.
const name =
picked
.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.pop() || picked;
return { path: picked, name };
}
@@ -0,0 +1,191 @@
/** Desktop read-through for mounted local folders, over the Tauri filesystem plugin. */
import { isTauri } from "@tauri-apps/api/core";
import {
mkdir,
readDir,
readFile,
stat,
writeFile,
} from "@tauri-apps/plugin-fs";
import {
directoryKey,
localFolderStorage,
} from "@core/services/localFolderStorage";
import type {
DiskDirEntry,
DiskFileEntry,
DiskListing,
} from "@core/services/localFolderContents";
export type { DiskDirEntry, DiskFileEntry, DiskListing };
/**
* Containment: these reads and writes run under a filesystem-wide Tauri capability, but
* the contract here is mounted directories only, so any path outside one is refused.
*/
async function isWithinMount(path: string): Promise<boolean> {
const pathKey = directoryKey(path);
const folders = await localFolderStorage.getAllFolders();
return folders
.map((folder) => directoryKey(folder.directory ?? ""))
.filter((key) => key.length > 0)
.some(
(dir) =>
pathKey === dir ||
pathKey.startsWith(dir.endsWith("/") ? dir : `${dir}/`),
);
}
/** Caps the listing; past the cap the freshest files win, which is what is wanted
* in a Downloads-like directory. */
const LIST_CAP = 500;
/**
* Every stat is a webview-to-Rust round trip, so listing cost is IPC latency, not disk
* speed.
*/
const STAT_BATCH = 32;
/** Lexical join: path.join is another IPC round trip, and a directory joined to a
* name it reported itself needs no normalisation a string cannot do. */
function joinPath(directory: string, name: string): string {
const sep = directory.includes("\\") ? "\\" : "/";
const base = directory.endsWith(sep)
? directory.slice(0, -sep.length)
: directory;
return `${base}${sep}${name}`;
}
export const canListDirectory = isTauri();
export async function listDirectory(
directory: string,
): Promise<DiskListing | null> {
if (!canListDirectory) return null;
const dirEntries = await readDir(directory);
// Visible entries, one level deep: a subdirectory lists when the user enters it.
const visible = dirEntries.filter((entry) => !entry.name.startsWith("."));
const directories: DiskDirEntry[] = visible
.filter((entry) => entry.isDirectory)
.map((entry) => ({
path: joinPath(directory, entry.name),
name: entry.name,
}))
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
const candidates = visible.filter((entry) => entry.isFile);
const files: DiskFileEntry[] = [];
for (let i = 0; i < candidates.length; i += STAT_BATCH) {
const batch = candidates.slice(i, i + STAT_BATCH);
const stats = await Promise.all(
batch.map(async (entry) => {
const path = joinPath(directory, entry.name);
try {
const info = await stat(path);
return {
path,
name: entry.name,
sizeBytes: info.size,
lastModified: info.mtime ? new Date(info.mtime).getTime() : 0,
};
} catch {
// Vanished or unreadable mid-listing; the next look tells the truth.
return null;
}
}),
);
for (const entry of stats) {
if (entry) files.push(entry);
}
}
files.sort((a, b) => b.lastModified - a.lastModified);
return { files: files.slice(0, LIST_CAP), directories };
}
export async function makeDiskDirectory(
parent: string,
name: string,
): Promise<string | null> {
if (!canListDirectory) return null;
if (!(await isWithinMount(parent))) return null;
const path = joinPath(parent, safeBaseName(name));
await mkdir(path);
return path;
}
/**
* The filesystem returns bytes and a name, never a MIME type, and everything downstream
* branches on File.type - an untyped File silently takes every "unknown format" path.
*/
const MIME_BY_EXTENSION: Record<string, string> = {
pdf: "application/pdf",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
svg: "image/svg+xml",
};
function mimeForName(name: string): string {
const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
return MIME_BY_EXTENSION[ext] ?? "";
}
export async function readDiskFile(entry: DiskFileEntry): Promise<File | null> {
if (!canListDirectory) return null;
if (!(await isWithinMount(entry.path))) return null;
const bytes = await readFile(entry.path);
return new File([new Uint8Array(bytes)], entry.name, {
type: mimeForName(entry.name),
lastModified: entry.lastModified || undefined,
});
}
/** How many "(n)" suffixes to try before conceding the directory is hostile. */
const UNIQUE_NAME_ATTEMPTS = 1000;
/**
* Names arrive from outside the app - zip entries, Content-Disposition - and this holds
* a filesystem-wide write scope.
*/
function safeBaseName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? "";
const trimmed = base.trim();
if (trimmed === "" || trimmed === "." || trimmed === "..") return "file";
return trimmed;
}
export async function writeDiskFile(
directory: string,
name: string,
bytes: Blob,
): Promise<string | null> {
if (!canListDirectory) return null;
if (!(await isWithinMount(directory))) return null;
name = safeBaseName(name);
const dot = name.lastIndexOf(".");
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : "";
const data = new Uint8Array(await bytes.arrayBuffer());
// An existing name keeps its file and the incomer takes " (n)", as the OS does.
for (let n = 0; n < UNIQUE_NAME_ATTEMPTS; n++) {
const candidate = n === 0 ? name : `${base} (${n})${ext}`;
const path = joinPath(directory, candidate);
try {
await writeFile(path, data, { createNew: true });
return candidate;
} catch (err) {
if (!isAlreadyExists(err)) throw err;
}
}
throw new Error(`No free name for ${name} in ${directory}`);
}
/** The plugin surfaces OS errors as text: EEXIST is 17, Windows 80 or 183. */
function isAlreadyExists(err: unknown): boolean {
const text = err instanceof Error ? err.message : String(err);
return /already exists|file exists|os error (17|80|183)\b/i.test(text);
}