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
|
# Local screenshot artifacts from *-screenshots.spec.ts
|
||||||
frontend/editor/screenshots/
|
frontend/editor/screenshots/
|
||||||
|
frontend/editor/src-tauri/libs/.variant
|
||||||
|
|||||||
@@ -4321,7 +4321,8 @@ download = "Download"
|
|||||||
downloadAll = "Download all"
|
downloadAll = "Download all"
|
||||||
downloadVersion = "Download this version"
|
downloadVersion = "Download this version"
|
||||||
dropOverlay = "Drop files to upload"
|
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"
|
duplicate = "Duplicate"
|
||||||
file = "File"
|
file = "File"
|
||||||
fileInfo = "File info"
|
fileInfo = "File info"
|
||||||
@@ -4334,12 +4335,19 @@ inPath = "in {{path}}"
|
|||||||
inWorkspace = "Open"
|
inWorkspace = "Open"
|
||||||
inWorkspaceAria = "Already in workspace"
|
inWorkspaceAria = "Already in workspace"
|
||||||
loading = "Loading…"
|
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_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)."
|
moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)."
|
||||||
moveTo = "Move to…"
|
moveTo = "Move to…"
|
||||||
newFolder = "New folder"
|
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."
|
newFolderTabUnavailable = "Switch to All or Cloud to create folders."
|
||||||
offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disabled. Check sign-in and storage configuration."
|
offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disabled. Check sign-in and storage configuration."
|
||||||
open = "Open"
|
open = "Open"
|
||||||
@@ -4347,6 +4355,7 @@ openVersionInWorkspace = "Open in workspace"
|
|||||||
originFilter = "Filter by source"
|
originFilter = "Filter by source"
|
||||||
refresh = "Refresh from server"
|
refresh = "Refresh from server"
|
||||||
remove = "Delete"
|
remove = "Delete"
|
||||||
|
removeLocalFolder = "Remove (files stay on disk)"
|
||||||
removeVersion = "Remove this version"
|
removeVersion = "Remove this version"
|
||||||
rename = "Rename"
|
rename = "Rename"
|
||||||
renamed = "Renamed"
|
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."
|
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"
|
selectedCount = "{{count}} selected"
|
||||||
selectFile = "Select file {{name}}"
|
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."
|
shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it."
|
||||||
shareManage = "Manage sharing"
|
shareManage = "Manage sharing"
|
||||||
showDetails = "Show details"
|
showDetails = "Show details"
|
||||||
@@ -4367,7 +4377,6 @@ summary_one = "{{count}} item"
|
|||||||
summary_other = "{{count}} items"
|
summary_other = "{{count}} items"
|
||||||
tree = "Folders"
|
tree = "Folders"
|
||||||
upload = "Upload"
|
upload = "Upload"
|
||||||
uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder."
|
|
||||||
uploadFromMobile = "Upload from Mobile"
|
uploadFromMobile = "Upload from Mobile"
|
||||||
versionActions = "Version actions"
|
versionActions = "Version actions"
|
||||||
versionCollapse = "Collapse middle versions"
|
versionCollapse = "Collapse middle versions"
|
||||||
@@ -4433,6 +4442,8 @@ title = "You haven't shared any files yet"
|
|||||||
[filesPage.error]
|
[filesPage.error]
|
||||||
actionFailed = "Could not {{action}}."
|
actionFailed = "Could not {{action}}."
|
||||||
actionFailedDetail = "Could not {{action}}: {{message}}"
|
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_one = "Couldn't delete 1 file from the cloud."
|
||||||
cloudDeleteFailed_other = "Couldn't delete {{count}} files from the cloud."
|
cloudDeleteFailed_other = "Couldn't delete {{count}} files from the cloud."
|
||||||
deleteFolderFailed = "Could not delete folder."
|
deleteFolderFailed = "Could not delete folder."
|
||||||
@@ -4445,8 +4456,14 @@ moveFilesFailed = "Could not move files."
|
|||||||
moveFilesFailedDetail = "Could not move files: {{message}}"
|
moveFilesFailedDetail = "Could not move files: {{message}}"
|
||||||
moveFolderFailed = "Could not move folder."
|
moveFolderFailed = "Could not move folder."
|
||||||
moveFolderFailedDetail = "Could not move folder: {{message}}"
|
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."
|
removeFilesFailed = "Could not remove files."
|
||||||
removeFilesFailedDetail = "Could not remove files: {{message}}"
|
removeFilesFailedDetail = "Could not remove files: {{message}}"
|
||||||
|
removeFolderFailed = "Could not remove folder."
|
||||||
|
removeFolderFailedDetail = "Could not remove folder: {{message}}"
|
||||||
uploadFilesFailed = "Could not upload files."
|
uploadFilesFailed = "Could not upload files."
|
||||||
uploadFilesFailedDetail = "Could not upload files: {{message}}"
|
uploadFilesFailedDetail = "Could not upload files: {{message}}"
|
||||||
|
|
||||||
@@ -4466,12 +4483,21 @@ activeCount = "{{count}} filters active"
|
|||||||
clearAll = "Clear filters"
|
clearAll = "Clear filters"
|
||||||
label = "Filters"
|
label = "Filters"
|
||||||
|
|
||||||
|
[filesPage.folderKind]
|
||||||
|
local = "Local folder"
|
||||||
|
virtual = "Browser folder"
|
||||||
|
|
||||||
[filesPage.folderName]
|
[filesPage.folderName]
|
||||||
cancel = "Cancel"
|
cancel = "Cancel"
|
||||||
error = "Could not save folder. Try again."
|
error = "Could not save folder. Try again."
|
||||||
label = "Folder name"
|
label = "Folder name"
|
||||||
placeholder = "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]
|
[filesPage.moveDialog]
|
||||||
cancel = "Cancel"
|
cancel = "Cancel"
|
||||||
confirm = "Move here"
|
confirm = "Move here"
|
||||||
@@ -4485,10 +4511,16 @@ newFolderPlaceholder = "Folder name"
|
|||||||
newFolderToggle = "Create new folder…"
|
newFolderToggle = "Create new folder…"
|
||||||
title = "Move to 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]
|
[filesPage.origin]
|
||||||
all = "All sources"
|
all = "All sources"
|
||||||
cloud = "Cloud"
|
cloud = "Cloud"
|
||||||
cloudHint = "Stored on the Stirling server"
|
cloudHint = "Stored on the Stirling server"
|
||||||
|
diskHint = "A file in the mounted folder on your disk"
|
||||||
local = "Local"
|
local = "Local"
|
||||||
localHint = "Only stored in this browser"
|
localHint = "Only stored in this browser"
|
||||||
shared = "Shared"
|
shared = "Shared"
|
||||||
|
|||||||
@@ -39,6 +39,18 @@
|
|||||||
"identifier": "fs:allow-read-file",
|
"identifier": "fs:allow-read-file",
|
||||||
"allow": [{ "path": "**" }]
|
"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",
|
"identifier": "fs:allow-write-file",
|
||||||
"allow": [{ "path": "**" }]
|
"allow": [{ "path": "**" }]
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
|||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
|
|
||||||
import { FileId } from "@app/types/file";
|
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 { useFolders } from "@app/contexts/FolderContext";
|
||||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||||
import { StirlingFileStub } from "@app/types/fileContext";
|
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 { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
|
||||||
import { findFolderIcon } from "@app/components/filesPage/folderIcons";
|
import { findFolderIcon } from "@app/components/filesPage/folderIcons";
|
||||||
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
|
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 { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||||
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
|
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
|
||||||
import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem";
|
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 type FilesPageViewMode = "grid" | "list";
|
||||||
|
|
||||||
export interface FilesPageEntry {
|
export interface FilesPageEntry {
|
||||||
kind: "folder" | "file";
|
kind: "folder" | "file" | "diskFile";
|
||||||
folder?: FolderRecord;
|
folder?: FolderRecord;
|
||||||
/** Number of files inside this folder (folder entries only). */
|
/** Number of files inside this folder (folder entries only). */
|
||||||
folderFileCount?: number;
|
folderFileCount?: number;
|
||||||
file?: StirlingFileStub;
|
file?: StirlingFileStub;
|
||||||
|
/** A file read straight off a mounted directory (kind "diskFile"). */
|
||||||
|
disk?: DiskFileEntry;
|
||||||
/** Parent breadcrumb path for search results outside the current folder. */
|
/** Parent breadcrumb path for search results outside the current folder. */
|
||||||
parentPath?: string;
|
parentPath?: string;
|
||||||
}
|
}
|
||||||
@@ -66,6 +116,7 @@ interface FileGridProps {
|
|||||||
onOpenFolder: (id: FolderId) => void;
|
onOpenFolder: (id: FolderId) => void;
|
||||||
/** "Add to workspace". */
|
/** "Add to workspace". */
|
||||||
onOpenFile: (file: StirlingFileStub) => void;
|
onOpenFile: (file: StirlingFileStub) => void;
|
||||||
|
onOpenDiskFile?: (entry: DiskFileEntry) => void;
|
||||||
onMoveFiles: (
|
onMoveFiles: (
|
||||||
fileIds: FileId[],
|
fileIds: FileId[],
|
||||||
targetFolderId: FolderId | null,
|
targetFolderId: FolderId | null,
|
||||||
@@ -383,6 +434,7 @@ function GridView(props: FileGridProps) {
|
|||||||
onSelectFile,
|
onSelectFile,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
onOpenDiskFile,
|
||||||
onMoveFiles,
|
onMoveFiles,
|
||||||
onMoveFolder,
|
onMoveFolder,
|
||||||
onRenameFolder,
|
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) {
|
if (entry.kind === "file" && entry.file) {
|
||||||
return (
|
return (
|
||||||
<FileCard
|
<FileCard
|
||||||
@@ -470,6 +531,12 @@ function FolderCard({
|
|||||||
}: FolderCardProps) {
|
}: FolderCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { serverReachable, setError } = useFolders();
|
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(
|
const offlineHint = t(
|
||||||
"filesPage.offlineNoFolderEdits",
|
"filesPage.offlineNoFolderEdits",
|
||||||
"Offline - folder changes are disabled.",
|
"Offline - folder changes are disabled.",
|
||||||
@@ -517,9 +584,7 @@ function FolderCard({
|
|||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "move";
|
||||||
}}
|
}}
|
||||||
{...dropHandlers}
|
{...dropHandlers}
|
||||||
className={`files-page-card is-folder${
|
className={`files-page-card is-folder${isDropTarget ? " is-drop-target" : ""}`}
|
||||||
isDropTarget ? " is-drop-target" : ""
|
|
||||||
}`}
|
|
||||||
onDoubleClick={onOpen}
|
onDoubleClick={onOpen}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -540,6 +605,13 @@ function FolderCard({
|
|||||||
fileCount={fileCount}
|
fileCount={fileCount}
|
||||||
iconGlyph={findFolderIcon(folder.icon)?.glyph}
|
iconGlyph={findFolderIcon(folder.icon)?.glyph}
|
||||||
/>
|
/>
|
||||||
|
<div className="files-page-card-origin">
|
||||||
|
<FileOriginBadge
|
||||||
|
origin={originBadge.origin}
|
||||||
|
tooltip={originBadge.tooltip}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="files-page-card-body">
|
<div className="files-page-card-body">
|
||||||
<div className="files-page-card-name" title={folder.name}>
|
<div className="files-page-card-name" title={folder.name}>
|
||||||
@@ -577,33 +649,51 @@ function FolderCard({
|
|||||||
>
|
>
|
||||||
{t("filesPage.open", "Open")}
|
{t("filesPage.open", "Open")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
{/* Only a mount root can be removed; a subdirectory is the
|
||||||
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
|
disk's, and the app never deletes directories. */}
|
||||||
onClick={onRename}
|
{editsHidden && folder.parentFolderId === null && (
|
||||||
disabled={!serverReachable}
|
<Menu.Item
|
||||||
title={!serverReachable ? offlineHint : undefined}
|
color="red"
|
||||||
>
|
leftSection={<DeleteIcon fontSize="small" />}
|
||||||
{t("filesPage.rename", "Rename")}
|
onClick={onDelete}
|
||||||
</Menu.Item>
|
>
|
||||||
<Menu.Divider />
|
{t(
|
||||||
<Menu.Label>
|
"filesPage.removeLocalFolder",
|
||||||
{t("filesPage.appearance.title", "Appearance")}
|
"Remove (files stay on disk)",
|
||||||
</Menu.Label>
|
)}
|
||||||
<FolderAppearancePicker
|
</Menu.Item>
|
||||||
folder={folder}
|
)}
|
||||||
onChange={onChangeAppearance}
|
{!editsHidden && (
|
||||||
disabled={!serverReachable}
|
<>
|
||||||
/>
|
<Menu.Item
|
||||||
<Menu.Divider />
|
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
|
||||||
<Menu.Item
|
onClick={onRename}
|
||||||
color="red"
|
disabled={editsDisabled}
|
||||||
leftSection={<DeleteIcon fontSize="small" />}
|
title={editsDisabled ? offlineHint : undefined}
|
||||||
onClick={onDelete}
|
>
|
||||||
disabled={!serverReachable}
|
{t("filesPage.rename", "Rename")}
|
||||||
title={!serverReachable ? offlineHint : undefined}
|
</Menu.Item>
|
||||||
>
|
<Menu.Divider />
|
||||||
{t("filesPage.deleteFolder", "Delete folder")}
|
<Menu.Label>
|
||||||
</Menu.Item>
|
{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.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
@@ -908,9 +998,7 @@ function FileCard({
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") onDoubleClick();
|
if (e.key === "Enter") onDoubleClick();
|
||||||
}}
|
}}
|
||||||
className={`files-page-card${isSelected ? " is-selected" : ""}${
|
className={`files-page-card${isSelected ? " is-selected" : ""}${isInWorkspace ? " is-in-workspace" : ""}`}
|
||||||
isInWorkspace ? " is-in-workspace" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isInWorkspace && (
|
{isInWorkspace && (
|
||||||
<span
|
<span
|
||||||
@@ -1011,6 +1099,7 @@ function ListView(
|
|||||||
onSetSelection,
|
onSetSelection,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
onOpenDiskFile,
|
||||||
onMoveFiles,
|
onMoveFiles,
|
||||||
onMoveFolder,
|
onMoveFolder,
|
||||||
onRenameFolder,
|
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) {
|
if (entry.kind === "file" && entry.file) {
|
||||||
return (
|
return (
|
||||||
<FileRow
|
<FileRow
|
||||||
@@ -1183,6 +1281,11 @@ function FolderRow({
|
|||||||
}: FolderRowProps) {
|
}: FolderRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { serverReachable, setError } = useFolders();
|
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(
|
const offlineHint = t(
|
||||||
"filesPage.offlineNoFolderEdits",
|
"filesPage.offlineNoFolderEdits",
|
||||||
"Offline - folder changes are disabled.",
|
"Offline - folder changes are disabled.",
|
||||||
@@ -1278,8 +1381,19 @@ function FolderRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
<span role="gridcell">{t("filesPage.folder", "Folder")}</span>
|
|
||||||
<span role="gridcell">
|
<span role="gridcell">
|
||||||
{fileCount === 0
|
{fileCount === 0
|
||||||
? "-"
|
? "-"
|
||||||
@@ -1308,33 +1422,51 @@ function FolderRow({
|
|||||||
>
|
>
|
||||||
{t("filesPage.open", "Open")}
|
{t("filesPage.open", "Open")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
{/* Only a mount root can be removed; a subdirectory is the
|
||||||
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
|
disk's, and the app never deletes directories. */}
|
||||||
onClick={onRename}
|
{editsHidden && folder.parentFolderId === null && (
|
||||||
disabled={!serverReachable}
|
<Menu.Item
|
||||||
title={!serverReachable ? offlineHint : undefined}
|
color="red"
|
||||||
>
|
leftSection={<DeleteIcon fontSize="small" />}
|
||||||
{t("filesPage.rename", "Rename")}
|
onClick={onDelete}
|
||||||
</Menu.Item>
|
>
|
||||||
<Menu.Divider />
|
{t(
|
||||||
<Menu.Label>
|
"filesPage.removeLocalFolder",
|
||||||
{t("filesPage.appearance.title", "Appearance")}
|
"Remove (files stay on disk)",
|
||||||
</Menu.Label>
|
)}
|
||||||
<FolderAppearancePicker
|
</Menu.Item>
|
||||||
folder={folder}
|
)}
|
||||||
onChange={onChangeAppearance}
|
{!editsHidden && (
|
||||||
disabled={!serverReachable}
|
<>
|
||||||
/>
|
<Menu.Item
|
||||||
<Menu.Divider />
|
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
|
||||||
<Menu.Item
|
onClick={onRename}
|
||||||
color="red"
|
disabled={editsDisabled}
|
||||||
leftSection={<DeleteIcon fontSize="small" />}
|
title={editsDisabled ? offlineHint : undefined}
|
||||||
onClick={onDelete}
|
>
|
||||||
disabled={!serverReachable}
|
{t("filesPage.rename", "Rename")}
|
||||||
title={!serverReachable ? offlineHint : undefined}
|
</Menu.Item>
|
||||||
>
|
<Menu.Divider />
|
||||||
{t("filesPage.deleteFolder", "Delete folder")}
|
<Menu.Label>
|
||||||
</Menu.Item>
|
{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.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</span>
|
</span>
|
||||||
@@ -1402,9 +1534,7 @@ function FileRow({
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") onOpen();
|
if (e.key === "Enter") onOpen();
|
||||||
}}
|
}}
|
||||||
className={`files-page-list-row${isSelected ? " is-selected" : ""}${
|
className={`files-page-list-row${isSelected ? " is-selected" : ""}${isInWorkspace ? " is-in-workspace" : ""}`}
|
||||||
isInWorkspace ? " is-in-workspace" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* Each direct child is a gridcell: a role="row" may only own cells, so the
|
{/* 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.
|
checkbox and the actions menu have to sit inside one.
|
||||||
@@ -1516,3 +1646,194 @@ function FileRow({
|
|||||||
|
|
||||||
// Re-export root constant for caller convenience
|
// Re-export root constant for caller convenience
|
||||||
export { ROOT_FOLDER_ID };
|
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 {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
Group,
|
Group,
|
||||||
|
Menu,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
Select,
|
Select,
|
||||||
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -32,6 +34,9 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
|||||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
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 RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
import { FilesToolbarBulkMenu } from "@app/components/filesPage/FilesToolbarBulkMenu";
|
import { FilesToolbarBulkMenu } from "@app/components/filesPage/FilesToolbarBulkMenu";
|
||||||
import { FilesToolbarCount } from "@app/components/filesPage/FilesToolbarCount";
|
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 { useFileActions } from "@app/contexts/file/fileHooks";
|
||||||
import { useAllFiles } from "@app/contexts/FileContext";
|
import { useAllFiles } from "@app/contexts/FileContext";
|
||||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||||
|
import { useServerFolderBlock } from "@app/hooks/useServerFolderBlock";
|
||||||
import {
|
import {
|
||||||
useNavigationActions,
|
useNavigationActions,
|
||||||
useNavigationGuard,
|
useNavigationGuard,
|
||||||
@@ -60,7 +66,7 @@ import { getFileOrigin } from "@app/components/filesPage/fileOrigin";
|
|||||||
|
|
||||||
import { FileId } from "@app/types/file";
|
import { FileId } from "@app/types/file";
|
||||||
import { StirlingFileStub } from "@app/types/fileContext";
|
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 { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
|
||||||
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
|
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 BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
||||||
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
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 { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
|
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
|
||||||
import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog";
|
import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog";
|
||||||
@@ -201,6 +221,7 @@ export default function FileManagerView() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setCurrentFolderId = folders.setCurrentFolderId;
|
const setCurrentFolderId = folders.setCurrentFolderId;
|
||||||
|
const resolveDiskFolder = folders.resolveDiskFolder;
|
||||||
const foldersById = folders.foldersById;
|
const foldersById = folders.foldersById;
|
||||||
const currentFolderId = folders.currentFolderId;
|
const currentFolderId = folders.currentFolderId;
|
||||||
|
|
||||||
@@ -212,10 +233,13 @@ export default function FileManagerView() {
|
|||||||
setCurrentFolderId(ROOT_FOLDER_ID);
|
setCurrentFolderId(ROOT_FOLDER_ID);
|
||||||
} else if (foldersById.has(param as FolderId)) {
|
} else if (foldersById.has(param as FolderId)) {
|
||||||
setCurrentFolderId(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 {
|
} else {
|
||||||
setCurrentFolderId(ROOT_FOLDER_ID);
|
setCurrentFolderId(ROOT_FOLDER_ID);
|
||||||
}
|
}
|
||||||
}, [location.pathname, foldersById, setCurrentFolderId]);
|
}, [location.pathname, foldersById, setCurrentFolderId, resolveDiskFolder]);
|
||||||
|
|
||||||
// Bounce off any share-related tab when sharing isn't enabled.
|
// Bounce off any share-related tab when sharing isn't enabled.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -275,6 +299,8 @@ export default function FileManagerView() {
|
|||||||
}
|
}
|
||||||
const lc = search.toLowerCase();
|
const lc = search.toLowerCase();
|
||||||
const matched = folders.folders.filter((f) => {
|
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) {
|
if (search) {
|
||||||
// Subtree-wide name match; exclude the current folder itself.
|
// Subtree-wide name match; exclude the current folder itself.
|
||||||
return (
|
return (
|
||||||
@@ -296,10 +322,11 @@ export default function FileManagerView() {
|
|||||||
// Tab overrides folder navigation for Local/Recent/Shared.
|
// Tab overrides folder navigation for Local/Recent/Shared.
|
||||||
switch (currentTab) {
|
switch (currentTab) {
|
||||||
case "local":
|
case "local":
|
||||||
// Local = files with no server copy. folderId is forced null on this
|
// Both halves: a local file inside a browser folder belongs to that folder,
|
||||||
// path (cf. file.ts comment), but we check remoteStorageId too so
|
// not here as well.
|
||||||
// stale local-folder rows from a pre-pivot DB don't slip through.
|
return allFiles.filter(
|
||||||
return allFiles.filter((f) => f.remoteStorageId == null);
|
(f) => f.remoteStorageId == null && (f.folderId ?? null) === null,
|
||||||
|
);
|
||||||
case "cloud":
|
case "cloud":
|
||||||
// Cloud bucket; search widens to subtree, else direct-folder match.
|
// Cloud bucket; search widens to subtree, else direct-folder match.
|
||||||
return allFiles.filter((f) => {
|
return allFiles.filter((f) => {
|
||||||
@@ -426,12 +453,141 @@ export default function FileManagerView() {
|
|||||||
[foldersById],
|
[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[]>(() => {
|
const entries = useMemo<FilesPageEntry[]>(() => {
|
||||||
// When searching, items may come from anywhere in the subtree, so we
|
// When searching, items may come from anywhere in the subtree, so we
|
||||||
// expose a "parentPath" subtitle whenever the item's parent differs from
|
// expose a "parentPath" subtitle whenever the item's parent differs from
|
||||||
// currentFolderId. When no search is active, every item is in the
|
// currentFolderId. When no search is active, every item is in the
|
||||||
// current folder by definition and the subtitle is suppressed.
|
// current folder by definition and the subtitle is suppressed.
|
||||||
const inSearch = search.length > 0;
|
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 [
|
return [
|
||||||
...visibleFolders.map<FilesPageEntry>((folder) => ({
|
...visibleFolders.map<FilesPageEntry>((folder) => ({
|
||||||
kind: "folder",
|
kind: "folder",
|
||||||
@@ -457,6 +613,9 @@ export default function FileManagerView() {
|
|||||||
filesPage.fileCountsByFolder,
|
filesPage.fileCountsByFolder,
|
||||||
search,
|
search,
|
||||||
currentFolderId,
|
currentFolderId,
|
||||||
|
currentLocalDirectory,
|
||||||
|
diskEntries,
|
||||||
|
filesPage.sortMode,
|
||||||
pathForFolderId,
|
pathForFolderId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -531,28 +690,46 @@ export default function FileManagerView() {
|
|||||||
// state - otherwise the file pops up the next time the user navigates
|
// state - otherwise the file pops up the next time the user navigates
|
||||||
// to /viewer or /tools, which reads as "auto-opened" and surprised
|
// to /viewer or /tools, which reads as "auto-opened" and surprised
|
||||||
// people every time. The grid will repaint via refresh() below.
|
// 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, {
|
const added = await addFiles(files, {
|
||||||
selectFiles: false,
|
selectFiles: false,
|
||||||
skipWorkspaceDispatch: true,
|
skipWorkspaceDispatch: true,
|
||||||
|
...(target ? { folderId: target as string } : {}),
|
||||||
});
|
});
|
||||||
const fileIds = added.map((f) => f.fileId);
|
const fileIds = added.map((f) => f.fileId);
|
||||||
const target = currentFolderId;
|
|
||||||
// Uploaded files land in Local (folderId stays null).
|
|
||||||
if (
|
if (
|
||||||
target !== null &&
|
target !== null &&
|
||||||
fileIds.length > 0 &&
|
fileIds.length > 0 &&
|
||||||
(currentTab === "all" || currentTab === "cloud")
|
targetFolder &&
|
||||||
|
folderKind(targetFolder) === "server"
|
||||||
) {
|
) {
|
||||||
folders.setError(
|
await moveFilesTo(fileIds, target);
|
||||||
t(
|
|
||||||
"filesPage.uploadedToLocal",
|
|
||||||
"Uploaded files start in Local. Use 'Save to cloud' to put them in a folder.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await refresh();
|
await refresh();
|
||||||
},
|
},
|
||||||
[addFiles, currentFolderId, currentTab, folders, refresh, t],
|
[addFiles, currentFolderId, currentTab, folders, moveFilesTo, refresh, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFileInputChange = useCallback(
|
const onFileInputChange = useCallback(
|
||||||
@@ -913,20 +1090,18 @@ export default function FileManagerView() {
|
|||||||
[selectedFiles, fileMap],
|
[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.
|
// null = New folder actionable; string = disabled tooltip reason.
|
||||||
const newFolderDisabledReason: string | null = useMemo(() => {
|
const newFolderDisabledReason: string | null = useMemo(() => {
|
||||||
// Guests can't use cloud folders at all - say so before any tab/storage
|
// Only All/Cloud render folders, so creating one elsewhere would look inert.
|
||||||
// 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.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
|
currentTab === "local" ||
|
||||||
currentTab === "recent" ||
|
currentTab === "recent" ||
|
||||||
currentTab === "shared" ||
|
currentTab === "shared" ||
|
||||||
currentTab === "sharedByMe"
|
currentTab === "sharedByMe"
|
||||||
@@ -936,14 +1111,32 @@ export default function FileManagerView() {
|
|||||||
"Switch to All or Cloud to create folders.",
|
"Switch to All or Cloud to create folders.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!folders.serverReachable) {
|
// A subfolder inherits kind server, so the blockers gate the button rather
|
||||||
return t(
|
// than letting the dialog open and fail at submit.
|
||||||
"filesPage.newFolderStorageDisabled",
|
if (
|
||||||
"Server folder storage isn't enabled. Ask your admin to turn it on.",
|
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;
|
return null;
|
||||||
}, [signInRequiredReason, currentTab, folders.serverReachable, t]);
|
}, [
|
||||||
|
currentTab,
|
||||||
|
currentLocalDirectory,
|
||||||
|
currentFolder,
|
||||||
|
folders.currentFolderId,
|
||||||
|
serverFolderDisabledReason,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="files-page" ref={dropZoneRef}>
|
<div className="files-page" ref={dropZoneRef}>
|
||||||
@@ -977,6 +1170,10 @@ export default function FileManagerView() {
|
|||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
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
|
// pullFromServer bumps the folder revision, which the
|
||||||
// FolderProvider's effect reacts to by re-running refresh() -
|
// FolderProvider's effect reacts to by re-running refresh() -
|
||||||
// no need to await folders.refresh() manually.
|
// no need to await folders.refresh() manually.
|
||||||
@@ -1045,15 +1242,69 @@ export default function FileManagerView() {
|
|||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
leftSection={<CreateNewFolderIcon fontSize="small" />}
|
leftSection={<CreateNewFolderIcon fontSize="small" />}
|
||||||
onClick={() => openNewFolderDialog()}
|
onClick={() =>
|
||||||
|
folders.currentFolderId !== null
|
||||||
|
? openNewFolderDialog()
|
||||||
|
: openNewFolderDialog(null, "server")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("filesPage.newFolder", "New folder")}
|
{t("filesPage.newFolder", "New folder")}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1647,7 +1898,7 @@ export default function FileManagerView() {
|
|||||||
>
|
>
|
||||||
<FileGrid
|
<FileGrid
|
||||||
entries={entries}
|
entries={entries}
|
||||||
loading={loading}
|
loading={loading || diskLoading}
|
||||||
currentTab={currentTab}
|
currentTab={currentTab}
|
||||||
searchActive={search.trim().length > 0}
|
searchActive={search.trim().length > 0}
|
||||||
serverReachable={folders.serverReachable}
|
serverReachable={folders.serverReachable}
|
||||||
@@ -1659,6 +1910,7 @@ export default function FileManagerView() {
|
|||||||
onSelectFile={handleSelectFile}
|
onSelectFile={handleSelectFile}
|
||||||
onSetSelection={setSelectedFileIds}
|
onSetSelection={setSelectedFileIds}
|
||||||
onOpenFolder={handleOpenFolder}
|
onOpenFolder={handleOpenFolder}
|
||||||
|
onOpenDiskFile={(entry) => void openDiskFile(entry)}
|
||||||
onOpenFile={handleOpenFile}
|
onOpenFile={handleOpenFile}
|
||||||
onMoveFiles={moveFilesTo}
|
onMoveFiles={moveFilesTo}
|
||||||
onMoveFolder={moveFolderTo}
|
onMoveFolder={moveFolderTo}
|
||||||
@@ -1692,8 +1944,12 @@ export default function FileManagerView() {
|
|||||||
// (disabled tooltips, native file picker, dialog) is
|
// (disabled tooltips, native file picker, dialog) is
|
||||||
// identical regardless of where the user clicks from.
|
// identical regardless of where the user clicks from.
|
||||||
onEmptyUpload={() => fileInputRef.current?.click()}
|
onEmptyUpload={() => fileInputRef.current?.click()}
|
||||||
onEmptyCreateFolder={() => openNewFolderDialog()}
|
onEmptyCreateFolder={createFolderHere}
|
||||||
newFolderDisabledReason={newFolderDisabledReason}
|
// 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 && (
|
{isDraggingExternal && (
|
||||||
<div className="files-page-drop-overlay" aria-live="polite">
|
<div className="files-page-drop-overlay" aria-live="polite">
|
||||||
@@ -1704,16 +1960,21 @@ export default function FileManagerView() {
|
|||||||
{t("filesPage.dropOverlay", "Drop files to upload")}
|
{t("filesPage.dropOverlay", "Drop files to upload")}
|
||||||
</span>
|
</span>
|
||||||
<span className="files-page-drop-overlay-sub">
|
<span className="files-page-drop-overlay-sub">
|
||||||
{/* Behavior contract: per handleNativeUpload above, all
|
{/* Behavior contract: per handleNativeUpload above, files
|
||||||
newly-uploaded files start in Local (folderId stays
|
dropped inside a folder on the All/Cloud views are
|
||||||
null) regardless of the current folder view. Saying
|
placed into it — a mount takes them onto the disk
|
||||||
"will land in {folder}" was a lie; tell the truth
|
itself. Other tabs land drops in Local, so the copy
|
||||||
so the user reaches for Save-to-cloud / Move-to when
|
must match. */}
|
||||||
they actually want a folder placement. */}
|
{(currentTab === "all" || currentTab === "cloud") &&
|
||||||
{t(
|
currentFolderId !== null
|
||||||
"filesPage.dropOverlaySub",
|
? t(
|
||||||
"Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder.",
|
"filesPage.dropOverlaySubFolder",
|
||||||
)}
|
"They'll be added to this folder.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"filesPage.dropOverlaySub",
|
||||||
|
"Files land in Local. Organise them into folders any time.",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1774,7 +2035,14 @@ export default function FileManagerView() {
|
|||||||
<MoveToFolderDialog
|
<MoveToFolderDialog
|
||||||
opened={moveDialog.open}
|
opened={moveDialog.open}
|
||||||
onClose={closeMoveDialog}
|
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}
|
initialFolderId={moveDialog.initial}
|
||||||
disabledFolderId={moveDialog.folderId}
|
disabledFolderId={moveDialog.folderId}
|
||||||
onConfirm={async (target) => {
|
onConfirm={async (target) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface FileOriginBadgeProps {
|
|||||||
origin: FileOrigin;
|
origin: FileOrigin;
|
||||||
/** Compact (icon-only) vs full (icon + text). */
|
/** Compact (icon-only) vs full (icon + text). */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
tooltip?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
@@ -44,6 +45,7 @@ const styles = {
|
|||||||
export function FileOriginBadge({
|
export function FileOriginBadge({
|
||||||
origin,
|
origin,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
tooltip,
|
||||||
}: FileOriginBadgeProps) {
|
}: FileOriginBadgeProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export function FileOriginBadge({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={config.tooltip} withinPortal>
|
<Tooltip label={tooltip ?? config.tooltip} withinPortal>
|
||||||
{badge}
|
{badge}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -582,8 +582,13 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0.4rem;
|
bottom: 0.4rem;
|
||||||
left: 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;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.files-page-card-origin > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* "Open" badge - file is currently loaded in the active workspace.
|
/* "Open" badge - file is currently loaded in the active workspace.
|
||||||
Solid pill with white text so it reads against any thumbnail
|
Solid pill with white text so it reads against any thumbnail
|
||||||
@@ -1494,3 +1499,14 @@
|
|||||||
transform: none;
|
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 { FileId } from "@app/types/file";
|
||||||
import {
|
import {
|
||||||
FolderId,
|
FolderId,
|
||||||
|
folderKind,
|
||||||
FolderRecord,
|
FolderRecord,
|
||||||
FolderTreeNode,
|
FolderTreeNode,
|
||||||
ROOT_FOLDER_ID,
|
ROOT_FOLDER_ID,
|
||||||
@@ -260,6 +261,12 @@ function TreeNodeRow({
|
|||||||
}: TreeNodeRowProps) {
|
}: TreeNodeRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { serverReachable, setError } = useFolders();
|
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 { currentTab } = useFilesPage();
|
||||||
const offlineHint = t(
|
const offlineHint = t(
|
||||||
"filesPage.offlineNoFolderEdits",
|
"filesPage.offlineNoFolderEdits",
|
||||||
@@ -433,8 +440,17 @@ function TreeNodeRow({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRenameFolder(node.folder);
|
onRenameFolder(node.folder);
|
||||||
}}
|
}}
|
||||||
disabled={!serverReachable}
|
disabled={editsDisabled}
|
||||||
title={!serverReachable ? offlineHint : undefined}
|
title={
|
||||||
|
kind === "local"
|
||||||
|
? t(
|
||||||
|
"filesPage.localFolderManagedByDisk",
|
||||||
|
"This folder is managed by its directory on disk.",
|
||||||
|
)
|
||||||
|
: editsDisabled
|
||||||
|
? offlineHint
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("filesPage.treeMenu.rename", "Rename")}
|
{t("filesPage.treeMenu.rename", "Rename")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -444,24 +460,48 @@ function TreeNodeRow({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRequestNewFolder(node.folder.id);
|
onRequestNewFolder(node.folder.id);
|
||||||
}}
|
}}
|
||||||
disabled={!serverReachable}
|
disabled={editsDisabled}
|
||||||
title={!serverReachable ? offlineHint : undefined}
|
title={
|
||||||
|
kind === "local"
|
||||||
|
? t(
|
||||||
|
"filesPage.localFolderManagedByDisk",
|
||||||
|
"This folder is managed by its directory on disk.",
|
||||||
|
)
|
||||||
|
: editsDisabled
|
||||||
|
? offlineHint
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("filesPage.treeMenu.newSubfolder", "New subfolder")}
|
{t("filesPage.treeMenu.newSubfolder", "New subfolder")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
{/* Every kind can be removed except a mount's subdirectory, which
|
||||||
color="red"
|
is the disk's — the app never deletes directories. A mount
|
||||||
leftSection={<DeleteOutlineIcon fontSize="small" />}
|
root's removal deletes the record and nothing on disk, so only
|
||||||
onClick={(e) => {
|
the server kind's reachability gate applies. */}
|
||||||
e.stopPropagation();
|
{(kind !== "local" || node.folder.parentFolderId === null) && (
|
||||||
onDeleteFolder(node.folder);
|
<Menu.Item
|
||||||
}}
|
color="red"
|
||||||
disabled={!serverReachable}
|
leftSection={<DeleteOutlineIcon fontSize="small" />}
|
||||||
title={!serverReachable ? offlineHint : undefined}
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
{t("filesPage.treeMenu.delete", "Delete folder")}
|
onDeleteFolder(node.folder);
|
||||||
</Menu.Item>
|
}}
|
||||||
|
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.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ function FileContextInner({
|
|||||||
skipWorkspaceDispatch?: boolean;
|
skipWorkspaceDispatch?: boolean;
|
||||||
skipUploadTracking?: boolean;
|
skipUploadTracking?: boolean;
|
||||||
derivedFromTool?: boolean;
|
derivedFromTool?: boolean;
|
||||||
|
/** Folder every added file is born into (see AddFileOptions). */
|
||||||
|
folderId?: string;
|
||||||
},
|
},
|
||||||
): Promise<StirlingFile[]> => {
|
): Promise<StirlingFile[]> => {
|
||||||
const stirlingFiles = await addFiles(
|
const stirlingFiles = await addFiles(
|
||||||
|
|||||||
@@ -13,8 +13,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { FileId } from "@app/types/file";
|
import { FileId } from "@app/types/file";
|
||||||
import { StirlingFileStub } from "@app/types/fileContext";
|
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 { fileStorage } from "@app/services/fileStorage";
|
||||||
|
import { writeIntoMount } from "@app/services/mountWrites";
|
||||||
import { folderSyncService } from "@app/services/folderSyncService";
|
import { folderSyncService } from "@app/services/folderSyncService";
|
||||||
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
|
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
|
||||||
import { reconcileServerFiles } from "@app/services/fileSyncService";
|
import { reconcileServerFiles } from "@app/services/fileSyncService";
|
||||||
@@ -59,6 +66,8 @@ export type FilesPageTab =
|
|||||||
export interface FolderNameDialogState {
|
export interface FolderNameDialogState {
|
||||||
mode: "new" | "rename" | null;
|
mode: "new" | "rename" | null;
|
||||||
parentId?: FolderId | null;
|
parentId?: FolderId | null;
|
||||||
|
/** For a root-level create: the kind the caller chose (menu, not dialog). */
|
||||||
|
kind?: FolderKind;
|
||||||
folder?: FolderRecord;
|
folder?: FolderRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +111,7 @@ interface FilesPageContextValue {
|
|||||||
|
|
||||||
// Dialog state
|
// Dialog state
|
||||||
folderNameDialog: FolderNameDialogState;
|
folderNameDialog: FolderNameDialogState;
|
||||||
openNewFolderDialog: (parentId?: FolderId | null) => void;
|
openNewFolderDialog: (parentId?: FolderId | null, kind?: FolderKind) => void;
|
||||||
openRenameFolderDialog: (folder: FolderRecord) => void;
|
openRenameFolderDialog: (folder: FolderRecord) => void;
|
||||||
closeFolderNameDialog: () => void;
|
closeFolderNameDialog: () => void;
|
||||||
submitFolderName: (name: string) => Promise<void>;
|
submitFolderName: (name: string) => Promise<void>;
|
||||||
@@ -243,8 +252,11 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
|||||||
useState<FolderNameDialogState>({ mode: null });
|
useState<FolderNameDialogState>({ mode: null });
|
||||||
|
|
||||||
const openNewFolderDialog = useCallback(
|
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],
|
[folders.currentFolderId],
|
||||||
);
|
);
|
||||||
@@ -260,9 +272,11 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const submitFolderName = useCallback(
|
const submitFolderName = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
if (folderNameDialog.mode === "new") {
|
if (folderNameDialog.mode === "new") {
|
||||||
|
// Chosen before the dialog opened, and only used at the root.
|
||||||
await folders.createFolder(
|
await folders.createFolder(
|
||||||
name,
|
name,
|
||||||
folderNameDialog.parentId ?? folders.currentFolderId,
|
folderNameDialog.parentId ?? folders.currentFolderId,
|
||||||
|
folderNameDialog.kind,
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
folderNameDialog.mode === "rename" &&
|
folderNameDialog.mode === "rename" &&
|
||||||
@@ -297,13 +311,89 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const moveFilesTo = useCallback(
|
const moveFilesTo = useCallback(
|
||||||
async (fileIds: FileId[], folderId: FolderId | null) => {
|
async (fileIds: FileId[], folderId: FolderId | null) => {
|
||||||
if (fileIds.length === 0) return;
|
if (fileIds.length === 0) return;
|
||||||
const stubs = fileIds
|
// fileMap is a render-time snapshot, so a file created moments ago is not in
|
||||||
.map((id) => fileMap.get(id))
|
// it yet. Storage is the truth, and falling back keeps it in the move.
|
||||||
.filter((s): s is StirlingFileStub => Boolean(s));
|
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);
|
const localOnly = stubs.filter((s) => s.remoteStorageId == null);
|
||||||
// Cloud list is mutated below with newly-promoted local files.
|
// Cloud list is mutated below with newly-promoted local files.
|
||||||
const cloudFiles = stubs.filter((s) => s.remoteStorageId != null);
|
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) {
|
if (folderId !== null && localOnly.length > 0) {
|
||||||
// Per-file uploadHistoryChain so each gets its own remoteStorageId.
|
// Per-file uploadHistoryChain so each gets its own remoteStorageId.
|
||||||
try {
|
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();
|
await refresh();
|
||||||
},
|
},
|
||||||
[indexedDB, refresh, fileMap, folders, t, fileActions],
|
[indexedDB, refresh, fileMap, folders, t, fileActions],
|
||||||
@@ -397,6 +497,22 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
return;
|
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);
|
await folders.moveFolder(folderId, newParentId);
|
||||||
},
|
},
|
||||||
[folders, t],
|
[folders, t],
|
||||||
@@ -554,10 +670,29 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const promptDeleteFolder = useCallback(
|
const promptDeleteFolder = useCallback(
|
||||||
(folder: FolderRecord) => {
|
(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;
|
const fileCount = filesInSubtree(folder.id).length;
|
||||||
setDeleteFolderDialog({ folder, fileCount });
|
setDeleteFolderDialog({ folder, fileCount });
|
||||||
},
|
},
|
||||||
[filesInSubtree],
|
[filesInSubtree, folders, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteFolder = useCallback(
|
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", () => ({
|
vi.mock("@app/contexts/IndexedDBContext", () => ({
|
||||||
useIndexedDB: () => ({
|
useIndexedDB: () => ({
|
||||||
clearFolderForFiles: vi.fn().mockResolvedValue(undefined),
|
clearFolderForFiles: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|||||||
@@ -26,16 +26,25 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { folderStorage } from "@app/services/folderStorage";
|
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 { folderSyncService } from "@app/services/folderSyncService";
|
||||||
import {
|
import {
|
||||||
FolderBreadcrumbEntry,
|
FolderBreadcrumbEntry,
|
||||||
FolderId,
|
FolderId,
|
||||||
|
FolderKind,
|
||||||
FolderRecord,
|
FolderRecord,
|
||||||
FolderTreeNode,
|
FolderTreeNode,
|
||||||
ROOT_FOLDER_ID,
|
ROOT_FOLDER_ID,
|
||||||
createFolderId,
|
createFolderId,
|
||||||
|
diskFolderId,
|
||||||
|
diskFolderPath,
|
||||||
|
folderKind,
|
||||||
|
isDiskFolderId,
|
||||||
pickFolderColor,
|
pickFolderColor,
|
||||||
} from "@app/types/folder";
|
} from "@app/types/folder";
|
||||||
|
import { directoryKey } from "@app/services/localFolderStorage";
|
||||||
|
import { makeDiskDirectory } from "@app/services/localFolderContents";
|
||||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import { useAuth } from "@app/auth/UseSession";
|
import { useAuth } from "@app/auth/UseSession";
|
||||||
@@ -88,9 +97,11 @@ interface FolderContextValue {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
reason?: "endpoint-missing" | "network" | "server" | "client";
|
reason?: "endpoint-missing" | "network" | "server" | "client";
|
||||||
}>;
|
}>;
|
||||||
|
/** Create a folder. A child takes its parent's kind; only a root create chooses. */
|
||||||
createFolder: (
|
createFolder: (
|
||||||
name: string,
|
name: string,
|
||||||
parentFolderId?: FolderId | null,
|
parentFolderId?: FolderId | null,
|
||||||
|
kind?: FolderKind,
|
||||||
) => Promise<FolderRecord>;
|
) => Promise<FolderRecord>;
|
||||||
renameFolder: (id: FolderId, name: string) => Promise<FolderRecord | null>;
|
renameFolder: (id: FolderId, name: string) => Promise<FolderRecord | null>;
|
||||||
moveFolder: (
|
moveFolder: (
|
||||||
@@ -102,6 +113,14 @@ interface FolderContextValue {
|
|||||||
appearance: { color?: string; icon?: string | null },
|
appearance: { color?: string; icon?: string | null },
|
||||||
) => Promise<FolderRecord | null>;
|
) => Promise<FolderRecord | null>;
|
||||||
deleteFolder: (id: FolderId) => Promise<FolderId[]>;
|
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[];
|
getChildFolderIds: (parentId: FolderId | null) => FolderId[];
|
||||||
isDescendant: (candidateId: FolderId, ancestorId: FolderId | null) => boolean;
|
isDescendant: (candidateId: FolderId, ancestorId: FolderId | null) => boolean;
|
||||||
@@ -146,6 +165,25 @@ function buildTree(folders: FolderRecord[]): FolderTreeNode[] {
|
|||||||
return build(ROOT_FOLDER_ID, 0);
|
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. */
|
/** Convert a server-side error to a banner-ready user message. */
|
||||||
function formatServerError(err: unknown): string {
|
function formatServerError(err: unknown): string {
|
||||||
if (err && typeof err === "object" && "response" in err) {
|
if (err && typeof err === "object" && "response" in err) {
|
||||||
@@ -241,7 +279,26 @@ function shouldStrandedReset(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FolderProvider({ children }: FolderProviderProps) {
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Start `false` so folder-mutation buttons are disabled until the first
|
// Start `false` so folder-mutation buttons are disabled until the first
|
||||||
@@ -269,9 +326,14 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
|||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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;
|
if (!mountedRef.current) return;
|
||||||
setFolders(all);
|
setFolders([...server, ...virtual, ...local]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[FolderContext] cache read failed", err);
|
console.error("[FolderContext] cache read failed", err);
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
@@ -342,7 +404,11 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
|||||||
console.warn("[FolderContext] cache replace failed", cacheErr);
|
console.warn("[FolderContext] cache replace failed", cacheErr);
|
||||||
}
|
}
|
||||||
if (mountedRef.current) {
|
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);
|
setServerReachable(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
@@ -519,11 +585,65 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
|||||||
[bumpFolderRevision, folders, handleStaleFolder],
|
[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(
|
const createFolder = useCallback(
|
||||||
async (
|
async (
|
||||||
name: string,
|
name: string,
|
||||||
parentFolderId: FolderId | null = currentFolderId,
|
parentFolderId: FolderId | null = currentFolderId,
|
||||||
|
kind?: FolderKind,
|
||||||
): Promise<FolderRecord> => {
|
): 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);
|
const color = pickFolderColor(name);
|
||||||
// Client-side id makes server idempotency check safe on retry.
|
// Client-side id makes server idempotency check safe on retry.
|
||||||
const id = createFolderId();
|
const id = createFolderId();
|
||||||
@@ -549,11 +669,41 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
|||||||
}
|
}
|
||||||
return result;
|
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(
|
const renameFolder = useCallback(
|
||||||
async (id: FolderId, name: string) => {
|
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(
|
return runFolderMutation(
|
||||||
() => folderSyncService.update(id, { name }),
|
() => folderSyncService.update(id, { name }),
|
||||||
async (record) => {
|
async (record) => {
|
||||||
@@ -565,11 +715,23 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
|||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[runFolderMutation],
|
[applyOwnedRecord, requireKind, runFolderMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const moveFolder = useCallback(
|
const moveFolder = useCallback(
|
||||||
async (id: FolderId, newParentId: FolderId | null) => {
|
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(
|
return runFolderMutation(
|
||||||
() =>
|
() =>
|
||||||
folderSyncService.update(id, {
|
folderSyncService.update(id, {
|
||||||
@@ -585,7 +747,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
|||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[runFolderMutation],
|
[applyOwnedRecord, requireKind, runFolderMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateFolderAppearance = useCallback(
|
const updateFolderAppearance = useCallback(
|
||||||
@@ -593,6 +755,23 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
|||||||
id: FolderId,
|
id: FolderId,
|
||||||
appearance: { color?: string; icon?: string | null },
|
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(
|
return runFolderMutation(
|
||||||
() =>
|
() =>
|
||||||
folderSyncService.update(id, {
|
folderSyncService.update(id, {
|
||||||
@@ -608,11 +787,50 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
|||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[runFolderMutation],
|
[applyOwnedRecord, requireKind, runFolderMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteFolder = useCallback(
|
const deleteFolder = useCallback(
|
||||||
async (id: FolderId): Promise<FolderId[]> => {
|
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
|
// Custom path (not runFolderMutation) because we have two best-effort
|
||||||
// cleanups to coordinate, and need to reset currentFolderId BEFORE the
|
// cleanups to coordinate, and need to reset currentFolderId BEFORE the
|
||||||
// cleanups so the user isn't stranded inside a tombstone if the cache
|
// cleanups so the user isn't stranded inside a tombstone if the cache
|
||||||
@@ -680,9 +898,94 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
|||||||
currentFolderId,
|
currentFolderId,
|
||||||
folders,
|
folders,
|
||||||
handleStaleFolder,
|
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>(
|
const value = useMemo<FolderContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
folders,
|
folders,
|
||||||
@@ -698,6 +1001,9 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
|||||||
refresh,
|
refresh,
|
||||||
pullFromServer,
|
pullFromServer,
|
||||||
createFolder,
|
createFolder,
|
||||||
|
mountLocalFolder,
|
||||||
|
registerDiskSubfolders,
|
||||||
|
resolveDiskFolder,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
updateFolderAppearance,
|
updateFolderAppearance,
|
||||||
@@ -717,6 +1023,9 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
|||||||
refresh,
|
refresh,
|
||||||
pullFromServer,
|
pullFromServer,
|
||||||
createFolder,
|
createFolder,
|
||||||
|
mountLocalFolder,
|
||||||
|
registerDiskSubfolders,
|
||||||
|
resolveDiskFolder,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
updateFolderAppearance,
|
updateFolderAppearance,
|
||||||
|
|||||||
@@ -273,6 +273,12 @@ interface AddFileOptions {
|
|||||||
/** When true, marks every added stub as derivedFromTool so the policy
|
/** When true, marks every added stub as derivedFromTool so the policy
|
||||||
* auto-run skips it — used for policy outputs imported via addFiles. */
|
* auto-run skips it — used for policy outputs imported via addFiles. */
|
||||||
derivedFromTool?: boolean;
|
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
|
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
|
||||||
const fileStub = createNewStirlingFileStub(file, fileId);
|
const fileStub = createNewStirlingFileStub(file, fileId);
|
||||||
if (options.derivedFromTool) fileStub.derivedFromTool = true;
|
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
|
// 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
|
// viewer gate and modal queue pick it up immediately instead of after hydration
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export const useFileHandler = () => {
|
|||||||
autoUnzip?: boolean;
|
autoUnzip?: boolean;
|
||||||
/** Skip the upload metric - the file isn't new to the system (e.g. a copy). */
|
/** Skip the upload metric - the file isn't new to the system (e.g. a copy). */
|
||||||
skipUploadTracking?: boolean;
|
skipUploadTracking?: boolean;
|
||||||
|
/** Folder every added file is born into (see AddFileOptions). */
|
||||||
|
folderId?: string;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<StirlingFile[]> => {
|
): Promise<StirlingFile[]> => {
|
||||||
// Merge default options with passed options - passed options take precedence
|
// 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 { useFileManagement } from "@app/contexts/FileContext";
|
||||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||||
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
||||||
|
import { readDiskFile } from "@app/services/localFolderContents";
|
||||||
|
|
||||||
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ const LAZY_THUMB_CONCURRENCY = 2;
|
|||||||
let activeLazyThumbs = 0;
|
let activeLazyThumbs = 0;
|
||||||
const lazyThumbQueue: Array<() => Promise<void>> = [];
|
const lazyThumbQueue: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
function scheduleLazyThumb(task: () => Promise<void>): void {
|
export function scheduleLazyThumb(task: () => Promise<void>): void {
|
||||||
lazyThumbQueue.push(task);
|
lazyThumbQueue.push(task);
|
||||||
drainLazyThumbQueue();
|
drainLazyThumbQueue();
|
||||||
}
|
}
|
||||||
@@ -80,3 +81,92 @@ export function useLazyThumbnail(
|
|||||||
|
|
||||||
return thumb;
|
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,
|
useFilesPage,
|
||||||
} from "@app/contexts/FilesPageContext";
|
} from "@app/contexts/FilesPageContext";
|
||||||
import { useFolders } from "@app/contexts/FolderContext";
|
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 { useFileHandler } from "@app/hooks/useFileHandler";
|
||||||
import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
|
import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
|
||||||
import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
|
import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
|
||||||
@@ -771,6 +774,8 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
const filesPage = useFilesPage();
|
const filesPage = useFilesPage();
|
||||||
const folders = useFolders();
|
const folders = useFolders();
|
||||||
const { addFiles } = useFileHandler();
|
const { addFiles } = useFileHandler();
|
||||||
|
const { createFolderHere, createFolderHereBlockedReason } =
|
||||||
|
useNewFolderFlow();
|
||||||
|
|
||||||
const handleUpload = useCallback(
|
const handleUpload = useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
@@ -787,12 +792,19 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
[addFiles, filesPage, folders.currentFolderId],
|
[addFiles, filesPage, folders.currentFolderId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const newFolderDisabledReason = !folders.serverReachable
|
// Kind-aware: only a server folder's subfolder needs the server, and a mounted
|
||||||
? t(
|
// directory takes no subfolders from here at all.
|
||||||
"filesPage.newFolderStorageDisabled",
|
const railCurrentFolder = folders.currentFolderId
|
||||||
"Server folder storage isn't enabled. Ask your admin to turn it on.",
|
? folders.foldersById.get(folders.currentFolderId)
|
||||||
)
|
: undefined;
|
||||||
|
const railCurrentKind = railCurrentFolder
|
||||||
|
? folderKind(railCurrentFolder)
|
||||||
: null;
|
: null;
|
||||||
|
const serverFolderBlock = useServerFolderBlock();
|
||||||
|
const newFolderDisabledReason =
|
||||||
|
railCurrentKind === "server"
|
||||||
|
? serverFolderBlock
|
||||||
|
: createFolderHereBlockedReason;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileSidebar
|
<FileSidebar
|
||||||
@@ -803,7 +815,7 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
extraAction={{
|
extraAction={{
|
||||||
icon: <CreateNewFolderIcon />,
|
icon: <CreateNewFolderIcon />,
|
||||||
label: t("filesPage.newFolder", "New folder"),
|
label: t("filesPage.newFolder", "New folder"),
|
||||||
onClick: () => filesPage.openNewFolderDialog(),
|
onClick: createFolderHere,
|
||||||
disabled: newFolderDisabledReason !== null,
|
disabled: newFolderDisabledReason !== null,
|
||||||
disabledTooltip: newFolderDisabledReason ?? undefined,
|
disabledTooltip: newFolderDisabledReason ?? undefined,
|
||||||
testId: "files-rail-new-folder",
|
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 { StirlingFileStub, StirlingFile } from "@app/types/fileContext";
|
||||||
import { FileId } from "@app/types/fileContext";
|
import { FileId } from "@app/types/fileContext";
|
||||||
import { FolderId, parseFolderId } from "@app/types/folder";
|
import { FolderId, parseFolderId } from "@app/types/folder";
|
||||||
|
import { virtualFolderStorage } from "@app/services/virtualFolderStorage";
|
||||||
import {
|
import {
|
||||||
isZipBundle,
|
isZipBundle,
|
||||||
loadShareBundleEntries,
|
loadShareBundleEntries,
|
||||||
@@ -119,6 +120,15 @@ export async function reconcileServerFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let combinedStubs: StirlingFileStub[];
|
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(
|
const localRemoteIds = new Set(
|
||||||
localStubs
|
localStubs
|
||||||
.map((s) => s.remoteStorageId)
|
.map((s) => s.remoteStorageId)
|
||||||
@@ -202,7 +212,12 @@ export async function reconcileServerFiles(
|
|||||||
// Server is authoritative for cloud-stored files. Don't fall back to
|
// Server is authoritative for cloud-stored files. Don't fall back to
|
||||||
// stub.folderId on null - that would resurrect a stale folder pointer
|
// stub.folderId on null - that would resurrect a stale folder pointer
|
||||||
// after the server SET_NULL'd it (e.g. owner deleted the folder).
|
// 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.
|
* are all the server's job now.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FolderId, FolderRecord } from "@app/types/folder";
|
import { FolderId, FolderRecord, folderKind } from "@app/types/folder";
|
||||||
import {
|
import {
|
||||||
indexedDBManager,
|
indexedDBManager,
|
||||||
DATABASE_CONFIGS,
|
DATABASE_CONFIGS,
|
||||||
} from "@app/services/indexedDBManager";
|
} 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 {
|
class FolderStorageService {
|
||||||
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||||
private readonly storeName = "folders";
|
private readonly storeName = "folders";
|
||||||
@@ -43,6 +55,7 @@ class FolderStorageService {
|
|||||||
reject(transaction.error ?? new Error("folder cache replace aborted"));
|
reject(transaction.error ?? new Error("folder cache replace aborted"));
|
||||||
store.clear();
|
store.clear();
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
|
requireServerFolder(folder);
|
||||||
store.put(folder);
|
store.put(folder);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -50,6 +63,7 @@ class FolderStorageService {
|
|||||||
|
|
||||||
/** Insert or overwrite a single folder in the cache. */
|
/** Insert or overwrite a single folder in the cache. */
|
||||||
async upsertFolder(folder: FolderRecord): Promise<void> {
|
async upsertFolder(folder: FolderRecord): Promise<void> {
|
||||||
|
requireServerFolder(folder);
|
||||||
const db = await this.getDatabase();
|
const db = await this.getDatabase();
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const transaction = db.transaction([this.storeName], "readwrite");
|
const transaction = db.transaction([this.storeName], "readwrite");
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ function toFolderRecord(dto: ServerFolder): FolderRecord {
|
|||||||
dto.parentFolderId === null ? null : parseFolderId(dto.parentFolderId);
|
dto.parentFolderId === null ? null : parseFolderId(dto.parentFolderId);
|
||||||
return {
|
return {
|
||||||
id,
|
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,
|
name: dto.name,
|
||||||
parentFolderId,
|
parentFolderId,
|
||||||
color: dto.color ?? undefined,
|
color: dto.color ?? undefined,
|
||||||
|
|||||||
@@ -399,4 +399,71 @@ describe("IndexedDB migration (FILES store)", () => {
|
|||||||
TARGET_VERSION,
|
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 = {
|
export const DATABASE_CONFIGS = {
|
||||||
FILES: {
|
FILES: {
|
||||||
name: "stirling-pdf-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: [
|
stores: [
|
||||||
{
|
{
|
||||||
name: "files",
|
name: "files",
|
||||||
@@ -492,6 +494,27 @@ export const DATABASE_CONFIGS = {
|
|||||||
{ name: "createdAt", keyPath: "createdAt", unique: false },
|
{ 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,
|
} 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[];
|
sourceFileIds?: FileId[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The cloud folder this file lives in. Semantics:
|
* The folder this file lives in. Semantics by storage state:
|
||||||
* - `remoteStorageId == null` → file is local-only; folderId MUST be null.
|
* - Cloud-stored (`remoteStorageId != null`): server-authoritative — null is
|
||||||
* - `remoteStorageId != null && folderId == null` → file is at the cloud root.
|
* the cloud root, X is server folder X; the sync overwrites it.
|
||||||
* - `remoteStorageId != null && folderId == X` → file lives in cloud folder X.
|
* - 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`;
|
* The "Local" pseudo-folder in the UI is the predicate
|
||||||
* it has no corresponding {@code folderId} value. Folders are a server-only concept.
|
* `remoteStorageId == null && folderId == null`.
|
||||||
*/
|
*/
|
||||||
folderId?: 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. */
|
/** Members of {@link FOLDER_COLOR_PALETTE}. Use this rather than `string` to keep callers honest. */
|
||||||
export type FolderPaletteColor = (typeof FOLDER_COLOR_PALETTE)[number];
|
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. */
|
/** Persisted folder shape stored in IndexedDB. */
|
||||||
export interface FolderRecord {
|
export interface FolderRecord {
|
||||||
id: FolderId;
|
id: FolderId;
|
||||||
|
/** Read through {@link folderKind}, never directly. */
|
||||||
|
kind?: FolderKind;
|
||||||
name: string;
|
name: string;
|
||||||
parentFolderId: FolderId | null;
|
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. */
|
/** Hex colour - either a palette member or any custom hex from a future picker. */
|
||||||
color?: string;
|
color?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@@ -49,6 +59,11 @@ export interface FolderRecord {
|
|||||||
updatedAt: number;
|
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
|
* Folder tree node - derived from FolderRecord[] for rendering the tree
|
||||||
* navigator. Children are ordered by name (case-insensitive).
|
* navigator. Children are ordered by name (case-insensitive).
|
||||||
@@ -82,6 +97,40 @@ export function parseFolderId(value: unknown): FolderId {
|
|||||||
return value as 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 {
|
export function createFolderId(): FolderId {
|
||||||
return generateId() as 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;
|
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> {
|
async getServerConfig(): Promise<ServerConfig | null> {
|
||||||
const config = await this.getCurrentConfig();
|
const config = await this.getCurrentConfig();
|
||||||
return config.server_config;
|
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