mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
docs(folders): cut the comments back
Same facts, a third of the words. Mostly three- and four-line asides saying one thing, and rhetorical framing around explanations that stand up on their own.
This commit is contained in:
@@ -234,8 +234,7 @@ export default function FileManagerView() {
|
||||
} else if (foldersById.has(param as FolderId)) {
|
||||
setCurrentFolderId(param as FolderId);
|
||||
} else if (isDiskFolderId(param) && resolveDiskFolder(param as FolderId)) {
|
||||
// A mount subdirectory deep link: its record is rebuilt from the id,
|
||||
// and the map will carry it on the next render.
|
||||
// A mount subdirectory deep link: rebuilt from the id, mapped next render.
|
||||
setCurrentFolderId(param as FolderId);
|
||||
} else {
|
||||
setCurrentFolderId(ROOT_FOLDER_ID);
|
||||
@@ -300,9 +299,7 @@ export default function FileManagerView() {
|
||||
}
|
||||
const lc = search.toLowerCase();
|
||||
const matched = folders.folders.filter((f) => {
|
||||
// The Cloud tab is the server's view: browser folders hold files the
|
||||
// tab's file filter will never list (a count with an empty room), and
|
||||
// mounts aren't on the server at all.
|
||||
// The Cloud tab is the server's view: browser folders and mounts aren't on it.
|
||||
if (currentTab === "cloud" && folderKind(f) !== "server") return false;
|
||||
if (search) {
|
||||
// Subtree-wide name match; exclude the current folder itself.
|
||||
@@ -325,9 +322,7 @@ export default function FileManagerView() {
|
||||
// Tab overrides folder navigation for Local/Recent/Shared.
|
||||
switch (currentTab) {
|
||||
case "local":
|
||||
// Local is the pseudo-folder for files that live nowhere: no server
|
||||
// copy AND no folder membership (cf. file.ts). A local file placed in
|
||||
// a browser folder is IN that folder, not loose here too.
|
||||
// Local means no server copy AND no folder membership - not both places.
|
||||
return allFiles.filter(
|
||||
(f) => f.remoteStorageId == null && (f.folderId ?? null) === null,
|
||||
);
|
||||
@@ -457,8 +452,7 @@ export default function FileManagerView() {
|
||||
[foldersById],
|
||||
);
|
||||
|
||||
// The directory is the source of truth: its contents are read fresh off
|
||||
// the disk whenever the user is inside the folder, never ingested to show.
|
||||
// Read-through: the directory is the source of truth, never ingested to show.
|
||||
const currentFolder = currentFolderId
|
||||
? folders.foldersById.get(currentFolderId)
|
||||
: undefined;
|
||||
@@ -469,16 +463,13 @@ export default function FileManagerView() {
|
||||
const { setError: setFolderError, registerDiskSubfolders } = folders;
|
||||
const [diskEntries, setDiskEntries] = useState<DiskFileEntry[]>([]);
|
||||
const [diskLoading, setDiskLoading] = useState(false);
|
||||
// Bumped after this view writes into the directory (an upload while inside
|
||||
// the mount), so the listing re-reads without leaving and re-entering.
|
||||
// Bumped when this view writes into the directory, so the listing re-reads.
|
||||
const [diskRefreshTick, setDiskRefreshTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!currentLocalDirectory || !canListDirectory) {
|
||||
setDiskEntries([]);
|
||||
// Also stand the loading flag down: when the user navigates OUT of a
|
||||
// mount mid-listing, the in-flight finally skips its reset (cancelled),
|
||||
// and this branch is the only code that runs — without the reset the
|
||||
// skeleton covers every folder for the rest of the session.
|
||||
// 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;
|
||||
}
|
||||
@@ -527,9 +518,8 @@ export default function FileManagerView() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// The stable setter, not the context object: that changes identity on
|
||||
// every folder mutation — including the setError call above, which would
|
||||
// make a failing listing re-trigger itself.
|
||||
// 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,
|
||||
@@ -539,8 +529,7 @@ export default function FileManagerView() {
|
||||
t,
|
||||
]);
|
||||
|
||||
// Opening a disk file loads its bytes into the workbench — the one moment
|
||||
// anything leaves the disk, and only because the user asked to work on it.
|
||||
// The one moment bytes leave the disk, and only because the user asked.
|
||||
const openDiskFile = useCallback(
|
||||
async (entry: DiskFileEntry) => {
|
||||
try {
|
||||
@@ -574,9 +563,7 @@ export default function FileManagerView() {
|
||||
// currentFolderId. When no search is active, every item is in the
|
||||
// current folder by definition and the subtitle is suppressed.
|
||||
const inSearch = search.length > 0;
|
||||
// Inside a mounted folder the listing IS the directory: its
|
||||
// subdirectories (registered as folders when listed) and its files.
|
||||
// Storage rows don't apply there.
|
||||
// Inside a mount the listing is the directory; storage rows don't apply.
|
||||
if (currentLocalDirectory) {
|
||||
const needle = search.toLowerCase();
|
||||
const compare: Record<
|
||||
@@ -708,8 +695,7 @@ export default function FileManagerView() {
|
||||
const target =
|
||||
currentTab === "all" || currentTab === "cloud" ? currentFolderId : null;
|
||||
const targetFolder = target ? folders.foldersById.get(target) : undefined;
|
||||
// A mount's contents ARE its directory: the upload writes straight to
|
||||
// the disk, never detouring through app storage.
|
||||
// A mount's upload writes straight to disk, never through app storage.
|
||||
if (targetFolder && folderKind(targetFolder) === "local") {
|
||||
const { failedCount } = await writeIntoMount(
|
||||
targetFolder.directory,
|
||||
@@ -727,10 +713,8 @@ export default function FileManagerView() {
|
||||
setDiskRefreshTick((tick) => tick + 1);
|
||||
return;
|
||||
}
|
||||
// Everywhere else the file is BORN in the folder — membership set
|
||||
// atomically with the stub, not by a move that could fail afterwards.
|
||||
// For a server folder that membership is local until the save-to-server
|
||||
// lands; moveFilesTo runs that sync (and is a no-op placement-wise).
|
||||
// Everywhere else membership is set with the stub rather than by a move that
|
||||
// could fail after. For a server folder it stays local until the save lands.
|
||||
const added = await addFiles(files, {
|
||||
selectFiles: false,
|
||||
skipWorkspaceDispatch: true,
|
||||
@@ -1108,11 +1092,9 @@ export default function FileManagerView() {
|
||||
[selectedFiles, fileMap],
|
||||
);
|
||||
|
||||
// Per-destination availability for the New-folder menu. The reasons render
|
||||
// inline as the disabled item's caption — the reason IS the information —
|
||||
// and the server one comes through a build seam, because what actually
|
||||
// blocks a server folder differs by platform (desktop's local mode has no
|
||||
// server at all, not a storage setting to flip).
|
||||
// Per-destination availability for the New-folder menu; the reason renders as the
|
||||
// disabled item's caption. The server one comes through a build seam because what
|
||||
// blocks it differs by platform - desktop's local mode has no server at all.
|
||||
const serverFolderDisabledReason = useServerFolderBlock() ?? undefined;
|
||||
|
||||
const { addLocalFolder, createFolderHere, createFolderHereBlockedReason } =
|
||||
@@ -1120,9 +1102,7 @@ export default function FileManagerView() {
|
||||
|
||||
// null = New folder actionable; string = disabled tooltip reason.
|
||||
const newFolderDisabledReason: string | null = useMemo(() => {
|
||||
// Folders only render in the All/Cloud views, so creating one from any
|
||||
// other tab would appear to do nothing. (Folders are NOT cloud-only —
|
||||
// this is about which views show them, not where they can exist.)
|
||||
// Only All/Cloud render folders, so creating one elsewhere would look inert.
|
||||
if (
|
||||
currentTab === "local" ||
|
||||
currentTab === "recent" ||
|
||||
@@ -1134,9 +1114,8 @@ export default function FileManagerView() {
|
||||
"Switch to All or Cloud to create folders.",
|
||||
);
|
||||
}
|
||||
// Inside a server folder the subfolder inherits kind server, so the
|
||||
// server-side blockers apply to the button itself — otherwise the dialog
|
||||
// opens only to fail at submit with a raw error.
|
||||
// A subfolder inherits kind server, so the blockers gate the button rather
|
||||
// than letting the dialog open and fail at submit.
|
||||
if (
|
||||
currentFolder &&
|
||||
folderKind(currentFolder) === "server" &&
|
||||
@@ -1144,10 +1123,8 @@ export default function FileManagerView() {
|
||||
) {
|
||||
return serverFolderDisabledReason;
|
||||
}
|
||||
// The web root creates on the server or not at all, so the same blockers
|
||||
// gate the button there. Desktop's root menu stays clickable regardless:
|
||||
// "Add local folder" needs no server, and the server item explains its
|
||||
// own disabled state inline.
|
||||
// The web root creates on the server or not at all. Desktop's stays clickable:
|
||||
// a local folder needs no server, and the server item explains itself inline.
|
||||
if (
|
||||
folders.currentFolderId === null &&
|
||||
!canPickDirectory &&
|
||||
@@ -1197,8 +1174,7 @@ export default function FileManagerView() {
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
// Inside a mount, "refresh" means the directory too — the
|
||||
// listing is read-through and only re-reads when told to.
|
||||
// In a mount, refresh means the directory: the listing only re-reads when told.
|
||||
if (currentLocalDirectory) {
|
||||
setDiskRefreshTick((tick) => tick + 1);
|
||||
}
|
||||
@@ -1271,10 +1247,8 @@ export default function FileManagerView() {
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : folders.currentFolderId !== null || !canPickDirectory ? (
|
||||
// Inside a folder there is nothing to choose: the subfolder
|
||||
// inherits its parent's kind. On the web the root offers no
|
||||
// choice either — folders live on the server, full stop —
|
||||
// so both cases are a plain click → name dialog.
|
||||
// Nothing to choose: a subfolder inherits its parent's kind, and on
|
||||
// the web everything lives on the server. Straight to the dialog.
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -1288,9 +1262,7 @@ export default function FileManagerView() {
|
||||
{t("filesPage.newFolder", "New folder")}
|
||||
</Button>
|
||||
) : (
|
||||
// Desktop root: the button IS the menu. The destinations
|
||||
// are peers — neither deserves to be the hidden one behind
|
||||
// a chevron — so every click shows both.
|
||||
// Desktop root: two peer destinations, so the button is the menu.
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
@@ -1977,10 +1949,8 @@ export default function FileManagerView() {
|
||||
// identical regardless of where the user clicks from.
|
||||
onEmptyUpload={() => fileInputRef.current?.click()}
|
||||
onEmptyCreateFolder={createFolderHere}
|
||||
// The CTA is a single-click shortcut, so it also blocks when
|
||||
// the shortcut has nothing safe to do (web root, no server) —
|
||||
// unlike the header button, whose root menu still offers the
|
||||
// explicit choices.
|
||||
// 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
|
||||
}
|
||||
@@ -2069,10 +2039,8 @@ export default function FileManagerView() {
|
||||
<MoveToFolderDialog
|
||||
opened={moveDialog.open}
|
||||
onClose={closeMoveDialog}
|
||||
// Only real destinations. Files can go anywhere — a mount takes them
|
||||
// by writing to its directory — but a FOLDER can only move within
|
||||
// its own kind, and never into a mount (a directory's subfolders are
|
||||
// the filesystem's business).
|
||||
// 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;
|
||||
|
||||
@@ -272,8 +272,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
const submitFolderName = useCallback(
|
||||
async (name: string) => {
|
||||
if (folderNameDialog.mode === "new") {
|
||||
// The kind was chosen before the dialog opened (the New-folder menu);
|
||||
// it only matters at the root — a subfolder inherits its parent's.
|
||||
// Chosen before the dialog opened, and only used at the root.
|
||||
await folders.createFolder(
|
||||
name,
|
||||
folderNameDialog.parentId ?? folders.currentFolderId,
|
||||
@@ -312,10 +311,8 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
const moveFilesTo = useCallback(
|
||||
async (fileIds: FileId[], folderId: FolderId | null) => {
|
||||
if (fileIds.length === 0) return;
|
||||
// fileMap is a render-time snapshot, and callers move files they
|
||||
// created moments ago (an upload straight into a folder) — those ids
|
||||
// aren't in any snapshot yet. Storage is the truth; falling back to it
|
||||
// keeps a just-born file from silently dropping out of the move.
|
||||
// fileMap is a render-time snapshot, so a file created moments ago is not in
|
||||
// it yet. Storage is the truth, and falling back keeps it in the move.
|
||||
const fetched = await Promise.all(
|
||||
fileIds.map(
|
||||
(id) => fileMap.get(id) ?? fileStorage.getStirlingFileStub(id),
|
||||
@@ -331,12 +328,9 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
const targetKind = targetFolder ? folderKind(targetFolder) : null;
|
||||
|
||||
if (targetKind === "local") {
|
||||
// Being "in" a mounted folder means being ON the disk: the move
|
||||
// writes each file into the directory (the read-through listing
|
||||
// shows it at once) and retires the app-side copy — only after the
|
||||
// bytes verifiably landed. Server files stay in the library: their
|
||||
// home is the server, and a disk copy would fork the document's
|
||||
// identity.
|
||||
// In a mount means on the disk: write each file into the directory, then
|
||||
// retire the app-side copy once the bytes verifiably landed. Server files
|
||||
// stay put - a disk copy would fork the document's identity.
|
||||
const { written, failedCount } = await writeIntoMount(
|
||||
targetFolder?.directory,
|
||||
localOnly.map((stub) => ({
|
||||
@@ -348,13 +342,11 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
.filter((_, i) => written[i])
|
||||
.map((stub) => stub.id);
|
||||
if (movedIds.length > 0) {
|
||||
// Take the superseded versions with it, or their bytes sit in
|
||||
// storage forever - invisible, because listings only show leaves.
|
||||
// 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, possibly two things to say — the user asked to
|
||||
// move N files and needs the whole account of what didn't.
|
||||
// One error slot, two possible failures: report both.
|
||||
const notices: string[] = [];
|
||||
if (failedCount > 0) {
|
||||
notices.push(
|
||||
@@ -382,10 +374,8 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
if (targetKind === "virtual") {
|
||||
// A virtual folder is browser-owned, so membership is too: local
|
||||
// files just point their folderId at it — no upload, no server call.
|
||||
// Server files stay out: their folder membership belongs to the
|
||||
// server, and the next sync would silently snap them back.
|
||||
// Browser-owned, so membership is too: local files just point folderId at
|
||||
// it. Server files stay out or the next sync snaps them back.
|
||||
if (cloudFiles.length > 0) {
|
||||
folders.setError(
|
||||
t(
|
||||
|
||||
@@ -98,10 +98,9 @@ interface FolderContextValue {
|
||||
reason?: "endpoint-missing" | "network" | "server" | "client";
|
||||
}>;
|
||||
/**
|
||||
* Create a folder. With a parent, the kind is the parent's — a subtree is
|
||||
* one kind throughout, since each kind has its own system of record and a
|
||||
* mixed chain would mean an ancestry no single store can vouch for. At the
|
||||
* root, `kind` decides, defaulting to server.
|
||||
* Create a folder. With a parent the kind is the parent's - one subtree, one
|
||||
* system of record, since a mixed chain has no store that can vouch for it. At
|
||||
* the root `kind` decides, defaulting to server.
|
||||
*/
|
||||
createFolder: (
|
||||
name: string,
|
||||
@@ -119,22 +118,19 @@ interface FolderContextValue {
|
||||
) => Promise<FolderRecord | null>;
|
||||
deleteFolder: (id: FolderId) => Promise<FolderId[]>;
|
||||
/**
|
||||
* Mount a directory on the machine as a local folder. Idempotent per
|
||||
* directory. Removing the mount later goes through {@link deleteFolder};
|
||||
* the directory itself is never touched by either.
|
||||
* Mount a directory as a local folder, idempotent per directory. Unmounting goes
|
||||
* through {@link deleteFolder}; neither touches the directory itself.
|
||||
*/
|
||||
mountLocalFolder: (directory: string, name: string) => Promise<FolderRecord>;
|
||||
/**
|
||||
* Subdirectories a mount listing found under `parentId`. They are not
|
||||
* stored — the directory is the record — so each listing replaces the
|
||||
* previous set for that parent, and a directory removed on disk drops out
|
||||
* on the next look.
|
||||
* Subdirectories a mount listing found under `parentId`. Not stored - the
|
||||
* directory is the record - so each listing replaces the previous set, and one
|
||||
* removed on disk drops out on the next look.
|
||||
*/
|
||||
registerDiskSubfolders: (parentId: FolderId, records: FolderRecord[]) => void;
|
||||
/**
|
||||
* Rebuild the records behind a disk-subfolder id (a `/files/<id>` link
|
||||
* arriving before any listing ran). True when the id sits under a known
|
||||
* mount and its chain is now registered.
|
||||
* 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;
|
||||
|
||||
@@ -296,8 +292,7 @@ function shouldStrandedReset(
|
||||
|
||||
export function FolderProvider({ children }: FolderProviderProps) {
|
||||
const [storedFolders, setFolders] = useState<FolderRecord[]>([]);
|
||||
// Subdirectories discovered inside mounts, keyed by the parent they were
|
||||
// listed under. In memory only: a directory is its own record.
|
||||
// Subdirectories found inside mounts, keyed by parent. In memory only.
|
||||
const [diskSubfolders, setDiskSubfolders] = useState<
|
||||
Map<FolderId, FolderRecord[]>
|
||||
>(() => new Map());
|
||||
@@ -343,8 +338,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Two systems of record: the server cache and the browser-owned virtual
|
||||
// store. The UI sees one list; kind says which rules each row follows.
|
||||
// Two systems of record behind one list; kind says which rules a row follows.
|
||||
const [server, virtual, local] = await Promise.all([
|
||||
folderStorage.getAllFolders(),
|
||||
virtualFolderStorage.getAllFolders(),
|
||||
@@ -422,8 +416,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
console.warn("[FolderContext] cache replace failed", cacheErr);
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
// Server-wins applies to server rows only: virtual and local folders
|
||||
// have no server copy, so a pull says nothing about them.
|
||||
// Server-wins is for server rows: the other kinds have no server copy.
|
||||
setFolders((prev) => [
|
||||
...remote,
|
||||
...prev.filter((f) => folderKind(f) !== "server"),
|
||||
@@ -620,19 +613,15 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
parentFolderId: FolderId | null = currentFolderId,
|
||||
kind?: FolderKind,
|
||||
): Promise<FolderRecord> => {
|
||||
// A child's kind is its parent's, always: one subtree, one system of
|
||||
// record. Only a root-level create gets to choose, and an unstated
|
||||
// choice means the server — the one kind every creating surface offers.
|
||||
// (Virtual is reachable only as a subfolder of an existing virtual
|
||||
// folder; local is never created here at all.)
|
||||
// A child's kind is its parent's. Only a root create chooses, defaulting to
|
||||
// server; virtual is reachable only under a virtual parent, local never here.
|
||||
const effectiveKind: FolderKind =
|
||||
parentFolderId !== null
|
||||
? requireKind(parentFolderId)
|
||||
: (kind ?? "server");
|
||||
if (effectiveKind === "local") {
|
||||
// A subfolder of a mount is a directory: make it on disk and present
|
||||
// it the way a listing would. Mount roots themselves come from the
|
||||
// picker, never from here.
|
||||
// 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");
|
||||
@@ -721,8 +710,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
async (id: FolderId, name: string) => {
|
||||
const kind = requireKind(id);
|
||||
if (kind === "local") {
|
||||
// The record's name is the directory's name; renaming the directory
|
||||
// is the filesystem's business, not Stirling's.
|
||||
// The record's name is the directory's; renaming it is the filesystem's job.
|
||||
throw new Error("A local folder takes its name from its directory");
|
||||
}
|
||||
if (kind === "virtual") {
|
||||
@@ -747,8 +735,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
const moveFolder = useCallback(
|
||||
async (id: FolderId, newParentId: FolderId | null) => {
|
||||
const kind = requireKind(id);
|
||||
// One subtree, one system of record: a folder can move to the root or
|
||||
// under a parent of its own kind, never across.
|
||||
// One subtree, one kind: to the root or under its own kind, never across.
|
||||
if (newParentId !== null && requireKind(newParentId) !== kind) {
|
||||
throw new Error("Folders can only move within their own kind");
|
||||
}
|
||||
@@ -785,16 +772,13 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
) => {
|
||||
const kind = requireKind(id);
|
||||
if (kind === "local") {
|
||||
// Nothing persists a local folder's cosmetics yet; its record lives
|
||||
// with whichever feature mounted it.
|
||||
// Nothing persists a local folder's cosmetics yet.
|
||||
throw new Error("Local folders cannot be recoloured yet");
|
||||
}
|
||||
if (kind === "virtual") {
|
||||
// Forward only the fields the picker actually sent: it sends one key
|
||||
// per interaction, and the store's spread persists an explicit
|
||||
// undefined — so passing both keys would erase whichever appearance
|
||||
// field the user did NOT touch. (icon: null means "clear the icon"
|
||||
// and maps to an explicit undefined deliberately.)
|
||||
// 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) {
|
||||
@@ -827,14 +811,12 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
const kind = requireKind(id);
|
||||
if (kind === "local") {
|
||||
if (isDiskFolderId(id)) {
|
||||
// A subdirectory is the disk's, not a record of ours; the app never
|
||||
// deletes directories.
|
||||
// A subdirectory is the disk's; the app never deletes directories.
|
||||
throw new Error(
|
||||
"Subfolders of a mounted directory are removed on disk",
|
||||
);
|
||||
}
|
||||
// Removing the mount removes the record and nothing else — the
|
||||
// directory on disk is the user's, always.
|
||||
// Removes the record and nothing else; the directory is the user's.
|
||||
await localFolderStorage.removeFolder(id);
|
||||
if (mountedRef.current) {
|
||||
setError(null);
|
||||
@@ -847,8 +829,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
return [id];
|
||||
}
|
||||
if (kind === "virtual") {
|
||||
// Same shape as the server path below: subtree delete, strand-reset,
|
||||
// then detach the files that pointed at any removed folder.
|
||||
// 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) {
|
||||
@@ -977,8 +958,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
const path = diskFolderPath(id);
|
||||
if (path === null) return false;
|
||||
const pathKey = directoryKey(path);
|
||||
// The mount whose directory contains the path — the deepest one, since
|
||||
// nested mounts are allowed and the closer root gives the shorter chain.
|
||||
// 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) {
|
||||
|
||||
@@ -83,15 +83,13 @@ export function useLazyThumbnail(
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache keyed by path + mtime + size, so an unchanged file never renders
|
||||
* twice and an edited one re-renders. Bounded: a mounted Downloads folder can
|
||||
* list hundreds of files, and each generation reads the file's FULL bytes off
|
||||
* disk, so the cache is what makes revisits and re-sorts free.
|
||||
* Keyed by path + mtime + size, so an unchanged file never renders twice and an
|
||||
* edited one does. Each generation reads the file's full bytes off disk, so this is
|
||||
* what makes revisits and re-sorts free.
|
||||
*/
|
||||
const diskThumbCache = new Map<string, string>();
|
||||
// Image thumbnails are data URLs whose size tracks the source image, so the
|
||||
// cache is bounded by BYTES, not entries — 300 photos would otherwise pin
|
||||
// gigabytes of strings for the process lifetime.
|
||||
// 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;
|
||||
|
||||
@@ -102,8 +100,7 @@ function cacheDiskThumb(key: string, url: string): void {
|
||||
diskThumbCacheBytes + url.length > DISK_THUMB_CACHE_MAX_BYTES &&
|
||||
diskThumbCache.size > 0
|
||||
) {
|
||||
// Maps iterate in insertion order; evicting the first entry makes this
|
||||
// FIFO — crude, but evicted thumbnails simply re-render on revisit.
|
||||
// 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);
|
||||
@@ -112,9 +109,7 @@ function cacheDiskThumb(key: string, url: string): void {
|
||||
diskThumbCacheBytes += url.length;
|
||||
}
|
||||
|
||||
// Reading a file's bytes is the expensive step, so it only happens for types
|
||||
// the generator can actually render — it branches on MIME (PDF and images)
|
||||
// and returns nothing for everything else, which must not cost a full read.
|
||||
// Reading the bytes is the expensive step, so only for types the generator renders.
|
||||
const THUMBABLE_EXTENSIONS = new Set([
|
||||
"pdf",
|
||||
"png",
|
||||
|
||||
@@ -6,11 +6,8 @@ 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 — the
|
||||
* files-page New-folder menu, the empty-state CTA, the sidebar rail — so the
|
||||
* surfaces present one behavior instead of drifting copies.
|
||||
*/
|
||||
/** 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();
|
||||
@@ -18,18 +15,15 @@ export function useNewFolderFlow() {
|
||||
const navigate = useNavigate();
|
||||
const serverFolderBlock = useServerFolderBlock();
|
||||
|
||||
// "Add local folder" needs no dialog at all: the native picker is the
|
||||
// whole interaction, and the directory's name is the folder's name.
|
||||
// Landing inside the fresh mount is the confirmation.
|
||||
// No dialog: the picker is the whole interaction and the directory names the
|
||||
// folder. Landing inside the fresh mount is the confirmation.
|
||||
const addLocalFolder = useCallback(async () => {
|
||||
try {
|
||||
const picked = await pickDirectory();
|
||||
if (!picked) return;
|
||||
const record = await folders.mountLocalFolder(picked.path, picked.name);
|
||||
// The URL is the source of truth for folder selection (the pathname →
|
||||
// state effect owns currentFolderId). Setting state directly here races
|
||||
// that effect — it re-runs on the same commit's foldersById change with
|
||||
// the old pathname and snaps the selection back to root.
|
||||
// The path owns folder selection. Setting state here races that effect, which
|
||||
// re-runs on the same commit with the old pathname and snaps back to root.
|
||||
navigate(`/files/${record.id}`);
|
||||
} catch (err) {
|
||||
folders.setError(
|
||||
@@ -43,11 +37,9 @@ export function useNewFolderFlow() {
|
||||
}
|
||||
}, [folders, navigate, t]);
|
||||
|
||||
// Single-click "New folder" for surfaces with no menu: the native picker
|
||||
// where the build can see the disk, the server folder on the web. When the
|
||||
// server can't take a folder the shortcut is blocked (see
|
||||
// createFolderHereBlockedReason) rather than acting silently. Inside a
|
||||
// folder the kind is inherited and none of this applies.
|
||||
// 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);
|
||||
@@ -57,8 +49,7 @@ export function useNewFolderFlow() {
|
||||
void addLocalFolder();
|
||||
return;
|
||||
}
|
||||
// Backstop for the blocked state — the surfaces disable themselves on
|
||||
// createFolderHereBlockedReason, so a click landing here means stale UI.
|
||||
// Backstop: surfaces disable themselves, so a click here means stale UI.
|
||||
if (serverFolderBlock === null) {
|
||||
openNewFolderDialog(null, "server");
|
||||
}
|
||||
@@ -69,9 +60,8 @@ export function useNewFolderFlow() {
|
||||
serverFolderBlock,
|
||||
]);
|
||||
|
||||
// Why the single-click surfaces should be disabled, or null when they can
|
||||
// act. Only the web root can block: desktop always has the picker, and
|
||||
// subfolders inherit their parent's kind.
|
||||
// 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
|
||||
|
||||
@@ -66,10 +66,9 @@ class VirtualFolderStorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a virtual folder under the given parent (null = root). The parent,
|
||||
* when set, must itself be a virtual folder: a virtual row can't hang off a
|
||||
* server folder, whose lifetime this browser doesn't control — a server-side
|
||||
* delete would orphan the whole virtual subtree with nothing to notice.
|
||||
* Create a virtual folder under `parent` (null = root). The parent must itself be
|
||||
* virtual: hanging one off a server folder means a server-side delete orphans the
|
||||
* whole subtree with nothing here to notice.
|
||||
*/
|
||||
async createFolder(
|
||||
name: string,
|
||||
|
||||
@@ -38,17 +38,14 @@ export const FOLDER_COLOR_PALETTE = [
|
||||
export type FolderPaletteColor = (typeof FOLDER_COLOR_PALETTE)[number];
|
||||
|
||||
/**
|
||||
* What kind of thing a folder is — three independent features that happen to
|
||||
* share a shape, not variants of one:
|
||||
* Three independent features that share a shape, not variants of one:
|
||||
*
|
||||
* - `server`: a folder in app storage. Lives in the server's database, synced
|
||||
* down and cached in IndexedDB; needs login + storage to exist.
|
||||
* - `virtual`: an organisation-only folder in this browser's IndexedDB. No
|
||||
* server involvement at all, so it works offline and on installs with
|
||||
* storage disabled.
|
||||
* - `local`: a real directory on the machine, mounted read-through — the
|
||||
* filesystem is the source of truth and Stirling holds no copy of its
|
||||
* contents, only this record of where it is.
|
||||
* - `server`: in the server's database, synced down and cached; needs login and
|
||||
* storage to exist.
|
||||
* - `virtual`: organisation only, in this browser's IndexedDB; works offline and
|
||||
* with storage disabled.
|
||||
* - `local`: a real directory, mounted read-through - the filesystem is the source
|
||||
* of truth and nothing of its contents is held here.
|
||||
*/
|
||||
export type FolderKind = "server" | "virtual" | "local";
|
||||
|
||||
@@ -56,9 +53,8 @@ export type FolderKind = "server" | "virtual" | "local";
|
||||
export interface FolderRecord {
|
||||
id: FolderId;
|
||||
/**
|
||||
* Absent means `server`: kinds arrived after rows already existed in user
|
||||
* databases and on the server wire, and every one of those is a server
|
||||
* folder. Read through {@link folderKind} rather than directly.
|
||||
* Absent means `server`: rows predating kinds are all server folders. Read
|
||||
* through {@link folderKind} rather than directly.
|
||||
*/
|
||||
kind?: FolderKind;
|
||||
name: string;
|
||||
|
||||
@@ -5,18 +5,16 @@ import type { ConnectionMode } from "@app/services/connectionModeService";
|
||||
import { useServerFolderBlock as useCoreServerFolderBlock } from "@core/hooks/useServerFolderBlock";
|
||||
|
||||
/**
|
||||
* Desktop's server-folder blocker speaks in connection modes. In local mode
|
||||
* there is no server to hold a folder — "storage isn't enabled" would send
|
||||
* the user hunting for a setting that doesn't exist, when signing in to
|
||||
* Stirling Cloud or connecting a self-hosted server IS the fix. The other
|
||||
* modes have a real server, so the shared account/storage reasons apply.
|
||||
* 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
|
||||
* when signing in or connecting a server is the fix. Other modes have a real server,
|
||||
* so the shared reasons apply.
|
||||
*/
|
||||
export function useServerFolderBlock(): string | null {
|
||||
const { t } = useTranslation();
|
||||
const coreReason = useCoreServerFolderBlock();
|
||||
// Seeded from the service's cache so a remount answers correctly on its
|
||||
// first frame; the effect only matters for the first-ever load and for
|
||||
// mode switches afterwards.
|
||||
// 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(),
|
||||
);
|
||||
|
||||
@@ -45,26 +45,19 @@ async function isWithinMount(path: string): Promise<boolean> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A directory can hold anything; the page shouldn't drown in it. Everything
|
||||
* up to the cap lists; past it, the freshest files win — for a Downloads-like
|
||||
* directory that is also the end the user is looking for.
|
||||
*/
|
||||
/** 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↔Rust round trip, so a big directory's listing cost
|
||||
* is IPC latency, not disk speed. Overlapping the calls turns N sequential
|
||||
* hops into N/BATCH; the batch bound keeps a 10k-file directory from opening
|
||||
* 10k requests at once.
|
||||
* Every stat is a webview-to-Rust round trip, so listing cost is IPC latency, not
|
||||
* disk speed. Overlapping turns N hops into N/BATCH, bounded so a 10k-file
|
||||
* directory does not open 10k requests at once.
|
||||
*/
|
||||
const STAT_BATCH = 32;
|
||||
|
||||
/**
|
||||
* Lexical join. The async path.join is itself an IPC round trip, which a
|
||||
* per-file loop cannot afford; joining a listed directory to a child name it
|
||||
* itself reported needs no normalization the string can't do.
|
||||
*/
|
||||
/** 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)
|
||||
@@ -80,8 +73,7 @@ export async function listDirectory(
|
||||
): Promise<DiskListing | null> {
|
||||
if (!canListDirectory) return null;
|
||||
const dirEntries = await readDir(directory);
|
||||
// Visible entries only: dotfiles are hidden on disk for a reason. One
|
||||
// level deep — a subdirectory is listed when the user enters it.
|
||||
// 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)
|
||||
@@ -121,10 +113,8 @@ export async function listDirectory(
|
||||
return { files: files.slice(0, LIST_CAP), directories };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subdirectory. Same containment and name rules as a file write;
|
||||
* the OS refuses an existing name, which is the answer the user wants.
|
||||
*/
|
||||
/** Create a subdirectory. Same containment and name rules as a file write; the OS
|
||||
* refuses an existing name, which is the answer the user wants. */
|
||||
export async function makeDiskDirectory(
|
||||
parent: string,
|
||||
name: string,
|
||||
@@ -137,10 +127,9 @@ export async function makeDiskDirectory(
|
||||
}
|
||||
|
||||
/**
|
||||
* The filesystem gives back bytes and a name, never a MIME type — but
|
||||
* everything downstream branches on File.type (the thumbnail generator's PDF
|
||||
* path, the workbench's format handling), and an untyped File silently takes
|
||||
* every "unknown format" branch. Recover the type from the extension.
|
||||
* 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. Recover it from the extension.
|
||||
*/
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
pdf: "application/pdf",
|
||||
@@ -172,10 +161,9 @@ export async function readDiskFile(entry: DiskFileEntry): Promise<File | null> {
|
||||
const UNIQUE_NAME_ATTEMPTS = 1000;
|
||||
|
||||
/**
|
||||
* File names come from outside the app's control — zip entries, a server's
|
||||
* Content-Disposition — and this function holds a filesystem-wide write
|
||||
* scope. Reduce whatever arrives to a plain basename so a name like
|
||||
* "..\\evil" cannot steer the write out of the chosen directory.
|
||||
* Names arrive from outside the app - zip entries, Content-Disposition - and this
|
||||
* holds a filesystem-wide write scope. Reduce whatever comes to a plain basename so
|
||||
* a traversal like "..\evil" cannot steer the write out of the chosen directory.
|
||||
*/
|
||||
function safeBaseName(name: string): string {
|
||||
const base = name.split(/[\\/]/).pop() ?? "";
|
||||
@@ -196,11 +184,9 @@ export async function writeDiskFile(
|
||||
const base = dot > 0 ? name.slice(0, dot) : name;
|
||||
const ext = dot > 0 ? name.slice(dot) : "";
|
||||
const data = new Uint8Array(await bytes.arrayBuffer());
|
||||
// The directory is the user's: an existing name keeps its file and the
|
||||
// incomer takes " (n)", the same convention the OS itself uses. The
|
||||
// exclusive create is what makes that a guarantee rather than a hope: a
|
||||
// probe-then-write would let a file created in between be overwritten,
|
||||
// while create-new fails atomically and the loop simply moves on.
|
||||
// An existing name keeps its file and the incomer takes " (n)", as the OS does.
|
||||
// The exclusive create is what makes that a guarantee: probe-then-write would let
|
||||
// a file created in between be overwritten, create-new fails and the loop moves on.
|
||||
for (let n = 0; n < UNIQUE_NAME_ATTEMPTS; n++) {
|
||||
const candidate = n === 0 ? name : `${base} (${n})${ext}`;
|
||||
const path = joinPath(directory, candidate);
|
||||
@@ -214,10 +200,8 @@ export async function writeDiskFile(
|
||||
throw new Error(`No free name for ${name} in ${directory}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin surfaces OS errors as text. EEXIST is 17; Windows reports
|
||||
* ERROR_FILE_EXISTS (80) or ERROR_ALREADY_EXISTS (183) depending on the call.
|
||||
*/
|
||||
/** The plugin surfaces OS errors as text. EEXIST is 17; Windows reports 80 or 183
|
||||
* depending on the call. */
|
||||
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