mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user