From 1b2a3118a6a1de615b80f73834b2e4891a3580f7 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:18:57 +0000 Subject: [PATCH] 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. --- .gitignore | 1 + .../public/locales/en-US/translation.toml | 40 +- .../src-tauri/capabilities/default.json | 12 + .../core/components/filesPage/FileGrid.tsx | 455 +++++++++++++++--- .../components/filesPage/FileManagerView.tsx | 366 ++++++++++++-- .../components/filesPage/FileOriginBadge.tsx | 4 +- .../core/components/filesPage/FilesPage.css | 16 + .../filesPage/FolderTreeSidebar.tsx | 72 ++- .../editor/src/core/contexts/FileContext.tsx | 2 + .../src/core/contexts/FilesPageContext.tsx | 153 +++++- .../src/core/contexts/FolderContext.test.tsx | 20 + .../src/core/contexts/FolderContext.tsx | 325 ++++++++++++- .../src/core/contexts/file/fileActions.ts | 9 + .../editor/src/core/hooks/useFileHandler.ts | 2 + .../editor/src/core/hooks/useLazyThumbnail.ts | 92 +++- .../editor/src/core/hooks/useNewFolderFlow.ts | 70 +++ .../src/core/hooks/useServerFolderBlock.ts | 27 ++ frontend/editor/src/core/pages/HomePage.tsx | 24 +- .../src/core/services/directoryPicker.ts | 15 + .../src/core/services/fileSyncService.ts | 17 +- .../editor/src/core/services/folderStorage.ts | 16 +- .../src/core/services/folderSyncService.ts | 3 + .../indexedDBManager.migration.test.ts | 67 +++ .../src/core/services/indexedDBManager.ts | 25 +- .../src/core/services/localFolderContents.ts | 54 +++ .../core/services/localFolderStorage.test.ts | 64 +++ .../src/core/services/localFolderStorage.ts | 119 +++++ .../editor/src/core/services/mountWrites.ts | 38 ++ .../services/virtualFolderStorage.test.ts | 78 +++ .../src/core/services/virtualFolderStorage.ts | 219 +++++++++ frontend/editor/src/core/types/file.ts | 15 +- frontend/editor/src/core/types/folder.test.ts | 31 ++ frontend/editor/src/core/types/folder.ts | 49 ++ .../src/desktop/hooks/useServerFolderBlock.ts | 43 ++ .../desktop/services/connectionModeService.ts | 5 + .../src/desktop/services/directoryPicker.ts | 27 ++ .../desktop/services/localFolderContents.ts | 191 ++++++++ 37 files changed, 2596 insertions(+), 170 deletions(-) create mode 100644 frontend/editor/src/core/hooks/useNewFolderFlow.ts create mode 100644 frontend/editor/src/core/hooks/useServerFolderBlock.ts create mode 100644 frontend/editor/src/core/services/directoryPicker.ts create mode 100644 frontend/editor/src/core/services/localFolderContents.ts create mode 100644 frontend/editor/src/core/services/localFolderStorage.test.ts create mode 100644 frontend/editor/src/core/services/localFolderStorage.ts create mode 100644 frontend/editor/src/core/services/mountWrites.ts create mode 100644 frontend/editor/src/core/services/virtualFolderStorage.test.ts create mode 100644 frontend/editor/src/core/services/virtualFolderStorage.ts create mode 100644 frontend/editor/src/core/types/folder.test.ts create mode 100644 frontend/editor/src/desktop/hooks/useServerFolderBlock.ts create mode 100644 frontend/editor/src/desktop/services/directoryPicker.ts create mode 100644 frontend/editor/src/desktop/services/localFolderContents.ts diff --git a/.gitignore b/.gitignore index ca683dc5d4..03a65905e1 100644 --- a/.gitignore +++ b/.gitignore @@ -312,3 +312,4 @@ docs/type3/signatures/ # Local screenshot artifacts from *-screenshots.spec.ts frontend/editor/screenshots/ +frontend/editor/src-tauri/libs/.variant diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 87d8ffb556..402142ea17 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4321,7 +4321,8 @@ download = "Download" downloadAll = "Download all" downloadVersion = "Download this version" dropOverlay = "Drop files to upload" -dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organize them into a folder." +dropOverlaySub = "Files land in Local. Organize them into folders any time." +dropOverlaySubFolder = "They'll be added to this folder." duplicate = "Duplicate" file = "File" fileInfo = "File info" @@ -4334,12 +4335,19 @@ inPath = "in {{path}}" inWorkspace = "Open" inWorkspaceAria = "Already in workspace" loading = "Loading…" -localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to organize it." +localFolderManagedByDisk = "This folder is managed by its directory on disk." +moveAcrossKindsBlocked = "These folders live in different places, so one can't go inside the other." +moveIntoMountCloudSkipped_one = "{{count}} server file stayed in your files. It lives on the server, not on this disk." +moveIntoMountCloudSkipped_other = "{{count}} server files stayed in your files. They live on the server, not on this disk." +moveIntoMountFailed_one = "{{count}} file could not be written into the folder." +moveIntoMountFailed_other = "{{count}} files could not be written into the folder." +moveIntoVirtualCloudSkipped_one = "{{count}} server file was left in place. Server files can't live in browser-only folders." +moveIntoVirtualCloudSkipped_other = "{{count}} server files were left in place. Server files can't live in browser-only folders." moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)." moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)." moveTo = "Move to…" newFolder = "New folder" -newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on." +newFolderStorageDisabled = "Server folder storage isn't enabled." newFolderTabUnavailable = "Switch to All or Cloud to create folders." offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disabled. Check sign-in and storage configuration." open = "Open" @@ -4347,6 +4355,7 @@ openVersionInWorkspace = "Open in workspace" originFilter = "Filter by source" refresh = "Refresh from server" remove = "Delete" +removeLocalFolder = "Remove (files stay on disk)" removeVersion = "Remove this version" rename = "Rename" renamed = "Renamed" @@ -4359,6 +4368,7 @@ selectAll = "Select all" selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range." selectedCount = "{{count}} selected" selectFile = "Select file {{name}}" +serverFolderNeedsConnection = "Sign in to Stirling Cloud or connect a self-hosted server to use server folders." shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it." shareManage = "Manage sharing" showDetails = "Show details" @@ -4367,7 +4377,6 @@ summary_one = "{{count}} item" summary_other = "{{count}} items" tree = "Folders" upload = "Upload" -uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder." uploadFromMobile = "Upload from Mobile" versionActions = "Version actions" versionCollapse = "Collapse middle versions" @@ -4433,6 +4442,8 @@ title = "You haven't shared any files yet" [filesPage.error] actionFailed = "Could not {{action}}." actionFailedDetail = "Could not {{action}}: {{message}}" +addFolderFailed = "Could not add the folder." +addFolderFailedDetail = "Could not add the folder: {{message}}" cloudDeleteFailed_one = "Couldn't delete 1 file from the cloud." cloudDeleteFailed_other = "Couldn't delete {{count}} files from the cloud." deleteFolderFailed = "Could not delete folder." @@ -4445,8 +4456,14 @@ moveFilesFailed = "Could not move files." moveFilesFailedDetail = "Could not move files: {{message}}" moveFolderFailed = "Could not move folder." moveFolderFailedDetail = "Could not move folder: {{message}}" +openDiskFileFailed = "Could not open {{name}}." +openDiskFileFailedDetail = "Could not open {{name}}: {{message}}" +readFolderFailed = "Could not read the folder." +readFolderFailedDetail = "Could not read the folder: {{message}}" removeFilesFailed = "Could not remove files." removeFilesFailedDetail = "Could not remove files: {{message}}" +removeFolderFailed = "Could not remove folder." +removeFolderFailedDetail = "Could not remove folder: {{message}}" uploadFilesFailed = "Could not upload files." uploadFilesFailedDetail = "Could not upload files: {{message}}" @@ -4466,12 +4483,21 @@ activeCount = "{{count}} filters active" clearAll = "Clear filters" label = "Filters" +[filesPage.folderKind] +local = "Local folder" +virtual = "Browser folder" + [filesPage.folderName] cancel = "Cancel" error = "Could not save folder. Try again." label = "Folder name" placeholder = "Folder name" +[filesPage.folderOrigin] +diskHint = "A folder mounted from a directory on your disk" +serverHint = "A folder stored on the Stirling server" +virtualHint = "A folder that lives only in this browser" + [filesPage.moveDialog] cancel = "Cancel" confirm = "Move here" @@ -4485,10 +4511,16 @@ newFolderPlaceholder = "Folder name" newFolderToggle = "Create new folder…" title = "Move to folder" +[filesPage.newFolderMenu] +addExisting = "Add local folder" +server = "New folder on the server" +serverHint = "Synced to your account, available wherever you sign in." + [filesPage.origin] all = "All sources" cloud = "Cloud" cloudHint = "Stored on the Stirling server" +diskHint = "A file in the mounted folder on your disk" local = "Local" localHint = "Only stored in this browser" shared = "Shared" diff --git a/frontend/editor/src-tauri/capabilities/default.json b/frontend/editor/src-tauri/capabilities/default.json index 4147b69ce0..34d333a3de 100644 --- a/frontend/editor/src-tauri/capabilities/default.json +++ b/frontend/editor/src-tauri/capabilities/default.json @@ -39,6 +39,18 @@ "identifier": "fs:allow-read-file", "allow": [{ "path": "**" }] }, + { + "identifier": "fs:allow-read-dir", + "allow": [{ "path": "**" }] + }, + { + "identifier": "fs:allow-stat", + "allow": [{ "path": "**" }] + }, + { + "identifier": "fs:allow-mkdir", + "allow": [{ "path": "**" }] + }, { "identifier": "fs:allow-write-file", "allow": [{ "path": "**" }] diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 334ad76805..8ab5bdbca9 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -20,7 +20,13 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; import SearchIcon from "@mui/icons-material/Search"; import { FileId } from "@app/types/file"; -import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder"; +import { + FolderId, + FolderRecord, + ROOT_FOLDER_ID, + folderKind, +} from "@app/types/folder"; +import type { DiskFileEntry } from "@app/services/localFolderContents"; import { useFolders } from "@app/contexts/FolderContext"; import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; import { StirlingFileStub } from "@app/types/fileContext"; @@ -36,20 +42,64 @@ import { FileOriginBadge } from "@app/components/filesPage/FileOriginBadge"; import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; import { findFolderIcon } from "@app/components/filesPage/folderIcons"; import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker"; -import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail"; +import { + useLazyThumbnail, + useDiskThumbnail, +} from "@app/hooks/useLazyThumbnail"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import type { FilesPageSortMode } from "@app/contexts/FilesPageContext"; import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem"; +/** + * The origin badge a folder wears, mirroring the one its files would: a server folder + * is Cloud, a virtual folder is Local (this browser), a mounted folder is On disk. + */ +function useFolderOriginBadge(folder: FolderRecord): { + origin: "cloud" | "local"; + tooltip: string; +} { + const { t } = useTranslation(); + switch (folderKind(folder)) { + case "virtual": + return { + origin: "local", + tooltip: t( + "filesPage.folderOrigin.virtualHint", + "A folder that lives only in this browser", + ), + }; + case "local": + return { + // Same mark as a virtual folder: what matters is that it lives on + // this device, not which corner of it. The tooltip says which. + origin: "local", + tooltip: t( + "filesPage.folderOrigin.diskHint", + "A folder mounted from a directory on your disk", + ), + }; + default: + return { + origin: "cloud", + tooltip: t( + "filesPage.folderOrigin.serverHint", + "A folder stored on the Stirling server", + ), + }; + } +} + export type FilesPageViewMode = "grid" | "list"; export interface FilesPageEntry { - kind: "folder" | "file"; + kind: "folder" | "file" | "diskFile"; folder?: FolderRecord; /** Number of files inside this folder (folder entries only). */ folderFileCount?: number; file?: StirlingFileStub; + /** A file read straight off a mounted directory (kind "diskFile"). */ + disk?: DiskFileEntry; /** Parent breadcrumb path for search results outside the current folder. */ parentPath?: string; } @@ -66,6 +116,7 @@ interface FileGridProps { onOpenFolder: (id: FolderId) => void; /** "Add to workspace". */ onOpenFile: (file: StirlingFileStub) => void; + onOpenDiskFile?: (entry: DiskFileEntry) => void; onMoveFiles: ( fileIds: FileId[], targetFolderId: FolderId | null, @@ -383,6 +434,7 @@ function GridView(props: FileGridProps) { onSelectFile, onOpenFolder, onOpenFile, + onOpenDiskFile, onMoveFiles, onMoveFolder, onRenameFolder, @@ -414,6 +466,15 @@ function GridView(props: FileGridProps) { /> ); } + if (entry.kind === "diskFile" && entry.disk) { + return ( + onOpenDiskFile?.(entry.disk!)} + /> + ); + } if (entry.kind === "file" && entry.file) { return (